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:
@@ -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 }> {
|
||||
if (!this.provider) {
|
||||
rootLogger.warn('[StorageProvider] deletePrefix chamado mas nenhum provider está registrado')
|
||||
|
||||
@@ -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<import('./StorageProvider').StorageHealthStatus>
|
||||
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<Buffer | null>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<string | null>(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<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 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 (
|
||||
<div
|
||||
@@ -45,18 +86,19 @@ export default function Avatar({ chat, size = 46, className = '' }: AvatarProps)
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{!showInitials && currentUrl ? (
|
||||
{!showInitials && displayUrl ? (
|
||||
<motion.img
|
||||
key={currentUrl}
|
||||
src={currentUrl}
|
||||
key={displayUrl}
|
||||
src={displayUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
crossOrigin="anonymous"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: isLoaded ? 1 : 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
onLoad={() => 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)
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 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:') && (
|
||||
<div className="absolute inset-0 bg-gray-200 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -42,6 +42,8 @@ function chatToLegacy(c: ReturnType<typeof useChatStore.getState>['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<typeof useChatStore.getState>['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),
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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 */
|
||||
private saveToLocalFallback(relativePath: string, buffer: Buffer, originalName: string, _category: string): { provider: string; path: string } {
|
||||
const dest = path.join(this.localFallbackDir, relativePath);
|
||||
|
||||
Reference in New Issue
Block a user