perf(inbox): cache de avatares no Wasabi e IndexedDB

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 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Deploy Agent
2026-05-11 16:02:08 +02:00
parent cdc32aa9b3
commit 2b9b855fc9
10 changed files with 268 additions and 67 deletions
@@ -90,6 +90,11 @@ class StorageProviderRegistry {
} }
} }
async getBuffer(path: string): Promise<Buffer | null> {
if (!this.provider) return null
return this.provider.getBuffer(path)
}
async deletePrefix(prefix: string): Promise<{ deleted: number }> { async deletePrefix(prefix: string): Promise<{ deleted: number }> {
if (!this.provider) { if (!this.provider) {
rootLogger.warn('[StorageProvider] deletePrefix chamado mas nenhum provider está registrado') rootLogger.warn('[StorageProvider] deletePrefix chamado mas nenhum provider está registrado')
@@ -119,6 +119,8 @@ export interface StorageUploadPayload {
usuarioSis?: string usuarioSis?: string
/** JID da sessão WhatsApp — usado para compor o path de mídia WhatsApp */ /** JID da sessão WhatsApp — usado para compor o path de mídia WhatsApp */
sessionJID?: string sessionJID?: string
/** Path completo no bucket — quando fornecido, substitui o path gerado automaticamente */
customPath?: string
file: { file: {
originalname: string originalname: string
buffer: Buffer buffer: Buffer
@@ -131,4 +133,6 @@ export interface StorageProviderInterface {
checkDetailedHealth(): Promise<import('./StorageProvider').StorageHealthStatus> checkDetailedHealth(): Promise<import('./StorageProvider').StorageHealthStatus>
upload(payload: StorageUploadPayload): Promise<{ provider: string; path: string }> upload(payload: StorageUploadPayload): Promise<{ provider: string; path: string }>
deletePrefix(prefix: string): Promise<{ deleted: number }> deletePrefix(prefix: string): Promise<{ deleted: number }>
/** Recupera os bytes de um arquivo pelo seu path relativo no bucket */
getBuffer(path: string): Promise<Buffer | null>
} }
@@ -19,6 +19,7 @@
* @see MessageRepository — busca e paginação de mensagens * @see MessageRepository — busca e paginação de mensagens
*/ */
import { Router, type Request, type Response } from 'express' import { Router, type Request, type Response } from 'express'
import { createHash } from 'crypto'
import { z } from 'zod' import { z } from 'zod'
import { ChatRepository } from './chat.repository' import { ChatRepository } from './chat.repository'
import { MessageRepository } from './message.repository' import { MessageRepository } from './message.repository'
@@ -114,11 +115,16 @@ export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
name: resolvedName, name: resolvedName,
phone: contact.phone, phone: contact.phone,
avatarUrl: contact.avatarUrl, 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, scoreReputacao: contact.scoreReputacao,
flagRestricao: contact.flagRestricao, flagRestricao: contact.flagRestricao,
} }
: isGroup : isGroup
? { name: resolvedName, phone: null, avatarUrl: null, scoreReputacao: 0, flagRestricao: false } ? { name: resolvedName, phone: null, avatarUrl: null, avatarVersion: null, scoreReputacao: 0, flagRestricao: false }
: null, : null,
lastMessage: lastMsg lastMessage: lastMsg
? { ? {
@@ -23,6 +23,7 @@ import { z } from 'zod'
import { prisma } from '../../infra/database/prisma' import { prisma } from '../../infra/database/prisma'
import { dragonfly } from '../../infra/cache/dragonfly' import { dragonfly } from '../../infra/cache/dragonfly'
import { logger } from '../../config/logger' import { logger } from '../../config/logger'
import { storageProvider } from '../../core/StorageProvider'
import type { WhatsAppConnectionManager } from './connection/WhatsAppConnectionManager' import type { WhatsAppConnectionManager } from './connection/WhatsAppConnectionManager'
import { sendRichMessage } from './rich-message' import { sendRichMessage } from './rich-message'
@@ -715,6 +716,26 @@ export function buildAvatarRoute(manager: WhatsAppConnectionManager): Router {
// Dragonfly indisponível — segue o fluxo normal sem quebrar // 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) // 1. Tenta avatarUrl já salvo no banco (funciona para todos os tipos de JID)
const contact = await prisma.contact.findFirst({ const contact = await prisma.contact.findFirst({
where: { tenantId, instanceId, jid }, where: { tenantId, instanceId, jid },
@@ -722,34 +743,48 @@ export function buildAvatarRoute(manager: WhatsAppConnectionManager): Router {
}) })
if (contact?.avatarUrl) { if (contact?.avatarUrl) {
// Revalidação condicional: se o browser mandou If-None-Match e bate, const storedUrl = contact.avatarUrl
// responde 304 sem baixar do CDN. Reduz o custo em rehit a ~zero. const isWasabi = !storedUrl.startsWith('http')
const etag = etagFor(contact.avatarUrl)
const etag = etagFor(storedUrl)
if (req.headers['if-none-match'] === etag) { if (req.headers['if-none-match'] === etag) {
res.set('ETag', etag) res.set('ETag', etag).set('Cache-Control', HIT_CACHE_CONTROL).status(304).end()
res.set('Cache-Control', HIT_CACHE_CONTROL)
res.status(304).end()
logger.debug({ jid, ms: Date.now() - t0, source: 'etag_304' }, '[Avatar] 304') logger.debug({ jid, ms: Date.now() - t0, source: 'etag_304' }, '[Avatar] 304')
return return
} }
if (isWasabi) {
// Já está no Wasabi — serve direto sem tocar no CDN do WhatsApp
try { try {
// Busca imagem do CDN do WhatsApp (URL temporária, expira em ~24h) const buffer = await storageProvider.getBuffer(storedUrl)
const upstream = await fetch(contact.avatarUrl) 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) { if (upstream.ok) {
const buffer = Buffer.from(await upstream.arrayBuffer()) const buffer = Buffer.from(await upstream.arrayBuffer())
res.set('Content-Type', upstream.headers.get('content-type') || 'image/jpeg') res.set('Content-Type', upstream.headers.get('content-type') || 'image/jpeg')
res.set('Cache-Control', HIT_CACHE_CONTROL) .set('Cache-Control', HIT_CACHE_CONTROL)
res.set('ETag', etag) .set('ETag', etag)
res.set('Content-Length', String(buffer.length)) .set('Content-Length', String(buffer.length))
res.send(buffer) .send(buffer)
logger.debug({ jid, ms: Date.now() - t0, source: 'db_cache' }, '[Avatar] served') logger.debug({ jid, ms: Date.now() - t0, source: 'db_cache' }, '[Avatar] served')
persistToWasabi(buffer)
return return
} }
// URL da DB expirou — tenta via Baileys abaixo // URL expirou — cai no fallback Baileys abaixo
logger.debug({ jid, ms: Date.now() - t0 }, '[Avatar] DB URL expirada, fallback Baileys') logger.debug({ jid, ms: Date.now() - t0 }, '[Avatar] DB URL expirada, fallback Baileys')
} catch { } catch { /* Erro de rede — cai no fallback Baileys abaixo */ }
// URL da DB expirou ou erro de rede — tenta via Baileys abaixo
} }
} }
@@ -764,24 +799,19 @@ export function buildAvatarRoute(manager: WhatsAppConnectionManager): Router {
const url = await sock.profilePictureUrl(jid, 'image') const url = await sock.profilePictureUrl(jid, 'image')
if (!url) { noAvatar(); return } if (!url) { noAvatar(); return }
// Faz proxy do buffer — evita CORS ao redirecionar para o CDN do WhatsApp
const upstream = await fetch(url) const upstream = await fetch(url)
if (!upstream.ok) { noAvatar(); return } if (!upstream.ok) { noAvatar(); return }
const buffer = Buffer.from(await upstream.arrayBuffer()) const buffer = Buffer.from(await upstream.arrayBuffer())
res.set('Content-Type', upstream.headers.get('content-type') || 'image/jpeg') res.set('Content-Type', upstream.headers.get('content-type') || 'image/jpeg')
res.set('Cache-Control', HIT_CACHE_CONTROL) .set('Cache-Control', HIT_CACHE_CONTROL)
res.set('ETag', etagFor(url)) .set('ETag', etagFor(url))
res.set('Content-Length', String(buffer.length)) .set('Content-Length', String(buffer.length))
res.send(buffer) .send(buffer)
logger.debug({ jid, ms: Date.now() - t0, source: 'baileys' }, '[Avatar] served') logger.debug({ jid, ms: Date.now() - t0, source: 'baileys' }, '[Avatar] served')
// Salva URL nova no banco para próximas requisições (fire-and-forget). // Persiste no Wasabi (fire-and-forget) e invalida negative cache
// Também invalida o negative cache, caso exista: agora temos foto. persistToWasabi(buffer)
await prisma.contact.updateMany({
where: { tenantId, instanceId, jid },
data: { avatarUrl: url },
}).catch(() => {})
dragonfly.del(noAvatarKey(tenantId, instanceId, jid)).catch(() => {}) dragonfly.del(noAvatarKey(tenantId, instanceId, jid)).catch(() => {})
} catch { } catch {
noAvatar() noAvatar()
@@ -1,11 +1,9 @@
'use client'; 'use client';
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import { getAvatarInitials, getAvatarColor } from '../../utils/inboxUtils';
getAvatarInitials, import { getAvatarFromCache, setAvatarInCache } from '../../hooks/useAvatarCache';
getAvatarColor,
} from '../../utils/inboxUtils';
interface AvatarProps { interface AvatarProps {
chat: any; chat: any;
@@ -14,30 +12,73 @@ interface AvatarProps {
} }
export default function Avatar({ chat, size = 46, className = '' }: AvatarProps) { export default function Avatar({ chat, size = 46, className = '' }: AvatarProps) {
const [showInitials, setShowInitials] = useState(false); const [displayUrl, setDisplayUrl] = useState<string | null>(null);
const [isLoaded, setIsLoaded] = useState(false); const [isLoaded, setIsLoaded] = useState(false);
const [showInitials, setShowInitials] = 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(() => { useEffect(() => {
cacheAttempted.current = false;
setIsLoaded(false); setIsLoaded(false);
setShowInitials(!imageUrl);
}, [chat?.remote_jid, imageUrl]);
const currentUrl = useMemo(() => { if (!imageUrl || !version) {
if (showInitials || !imageUrl) return null;
return imageUrl;
}, [showInitials, imageUrl]);
const handleImageError = () => {
setShowInitials(true); setShowInitials(true);
setDisplayUrl(null);
return;
}
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<HTMLImageElement>) => {
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 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 ( return (
<div <div
@@ -45,18 +86,19 @@ export default function Avatar({ chat, size = 46, className = '' }: AvatarProps)
style={{ width: size, height: size }} style={{ width: size, height: size }}
> >
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{!showInitials && currentUrl ? ( {!showInitials && displayUrl ? (
<motion.img <motion.img
key={currentUrl} key={displayUrl}
src={currentUrl} src={displayUrl}
alt="" alt=""
loading="lazy" loading="lazy"
decoding="async" decoding="async"
crossOrigin="anonymous"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: isLoaded ? 1 : 0 }} animate={{ opacity: isLoaded ? 1 : 0 }}
transition={{ duration: 0.3 }} transition={{ duration: 0.3 }}
onLoad={() => setIsLoaded(true)} onLoad={handleLoad}
onError={handleImageError} onError={handleError}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
) : ( ) : (
@@ -72,8 +114,8 @@ export default function Avatar({ chat, size = 46, className = '' }: AvatarProps)
)} )}
</AnimatePresence> </AnimatePresence>
{/* Skeleton / Placeholder while loading first image */} {/* Skeleton apenas quando buscando pelo proxy (não quando servindo do cache) */}
{!isLoaded && !showInitials && ( {!isLoaded && !showInitials && displayUrl && !displayUrl.startsWith('data:') && (
<div className="absolute inset-0 bg-gray-200 animate-pulse" /> <div className="absolute inset-0 bg-gray-200 animate-pulse" />
)} )}
</div> </div>
@@ -42,6 +42,8 @@ function chatToLegacy(c: ReturnType<typeof useChatStore.getState>['chats'][0]):
? getAvatarProxyUrl({ instance_name: c.instanceId, remote_jid: c.jid }, 'contact') ? getAvatarProxyUrl({ instance_name: c.instanceId, remote_jid: c.jid }, 'contact')
: null : null
const avatar_version: string | null = c.contactAvatarVersion ?? null
return { return {
id: c.id as any, id: c.id as any,
instance_name: c.instanceId, instance_name: c.instanceId,
@@ -49,6 +51,7 @@ function chatToLegacy(c: ReturnType<typeof useChatStore.getState>['chats'][0]):
phone: formattedPhone, phone: formattedPhone,
lead_name: c.contactName, lead_name: c.contactName,
avatar_url, avatar_url,
avatar_version,
bio: isGroup ? 'Detalhes do Grupo' : (formattedPhone || 'Clique para informações'), bio: isGroup ? 'Detalhes do Grupo' : (formattedPhone || 'Clique para informações'),
displayName, displayName,
subject: c.protocolNumber ? `#${c.protocolNumber}` : (isGroup ? null : formattedPhone), subject: c.protocolNumber ? `#${c.protocolNumber}` : (isGroup ? null : formattedPhone),
@@ -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 <img src>).
* 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<IDBDatabase> | null = null
function openDB(): Promise<IDBDatabase> {
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<string | null> {
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<void> {
if (typeof window === 'undefined') return
try {
const db = await openDB()
const key = `${jid}:${version}`
await new Promise<void>((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 */ }
}
@@ -24,6 +24,7 @@ function mapBackendChat(raw: any): Chat {
isArchived: raw.isArchived ?? false, isArchived: raw.isArchived ?? false,
contactName: raw.contact?.name ?? raw.contactName ?? null, contactName: raw.contact?.name ?? raw.contactName ?? null,
contactAvatarUrl: raw.contact?.avatarUrl ?? raw.contactAvatarUrl ?? null, contactAvatarUrl: raw.contact?.avatarUrl ?? raw.contactAvatarUrl ?? null,
contactAvatarVersion: raw.contact?.avatarVersion ?? raw.contactAvatarVersion ?? null,
contactPhone: raw.contact?.phone ?? raw.contactPhone ?? null, contactPhone: raw.contact?.phone ?? raw.contactPhone ?? null,
scoreReputacao: raw.contact?.scoreReputacao ?? raw.scoreReputacao ?? 0, scoreReputacao: raw.contact?.scoreReputacao ?? raw.scoreReputacao ?? 0,
flagRestricao: raw.contact?.flagRestricao ?? raw.flagRestricao ?? false, flagRestricao: raw.contact?.flagRestricao ?? raw.flagRestricao ?? false,
@@ -52,6 +53,7 @@ export interface Chat {
isArchived: boolean isArchived: boolean
contactName: string | null contactName: string | null
contactAvatarUrl: string | null contactAvatarUrl: string | null
contactAvatarVersion: string | null
contactPhone: string | null contactPhone: string | null
scoreReputacao: number scoreReputacao: number
flagRestricao: boolean flagRestricao: boolean
@@ -79,6 +79,7 @@ export interface NewWhatsChat {
phone: string phone: string
lead_name: string | null lead_name: string | null
avatar_url: string | null avatar_url: string | null
avatar_version: string | null
bio: string | null bio: string | null
displayName: string | null displayName: string | null
subject: string | null subject: string | null
@@ -324,10 +324,11 @@ class UnifiedStorageProviderPlugin implements PluginInstance, StorageProviderInt
const safeBase = originalName.replace(/[^a-zA-Z0-9._-]/g, '_'); const safeBase = originalName.replace(/[^a-zA-Z0-9._-]/g, '_');
const fileName = `${Date.now()}-${safeBase}`; const fileName = `${Date.now()}-${safeBase}`;
// Path no bucket: whatsapp/{tenantId}/{instanceId}/{file} ou {category}/{file} // Path no bucket: customPath (quando fornecido) > whatsapp/{t}/{i}/{file} > {cat}/{file}
const relativePath = (category === 'whatsapp' && payload.usuarioSis && payload.sessionJID) const relativePath = payload.customPath
?? ((category === 'whatsapp' && payload.usuarioSis && payload.sessionJID)
? `whatsapp/${payload.usuarioSis}/${payload.sessionJID}/${fileName}` ? `whatsapp/${payload.usuarioSis}/${payload.sessionJID}/${fileName}`
: `${category}/${fileName}`; : `${category}/${fileName}`);
if (!this.s3 || !this.bucket) { if (!this.s3 || !this.bucket) {
return this.saveToLocalFallback(relativePath, buffer, originalName, category); return this.saveToLocalFallback(relativePath, buffer, originalName, category);
@@ -353,6 +354,24 @@ class UnifiedStorageProviderPlugin implements PluginInstance, StorageProviderInt
} }
} }
async getBuffer(relativePath: string): Promise<Buffer | null> {
// 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 */ /** 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 } { private saveToLocalFallback(relativePath: string, buffer: Buffer, originalName: string, _category: string): { provider: string; path: string } {
const dest = path.join(this.localFallbackDir, relativePath); const dest = path.join(this.localFallbackDir, relativePath);