feat: sincronização de desenvolvimento local da VPS 4 para o Gitea

This commit is contained in:
VPS 4 Deploy Agent
2026-05-10 07:38:54 +02:00
parent 621fbfba64
commit 3396fa0598
402 changed files with 234697 additions and 0 deletions
@@ -0,0 +1,792 @@
/**
* Rotas de envio e operações WhatsApp.
*
* Endpoints:
* POST /api/instances/:instanceId/send — Envia mensagem de texto (com @mentions e reply)
* GET /api/instances/:instanceId/groups/:jid/participants — Lista participantes de grupo
* POST /api/instances/:instanceId/reconcile-names — Reconcilia nomes via pushName (bulk SQL)
* POST /api/instances/:instanceId/broadcast — Disparo em massa (async, até 500 números)
* GET /api/instances/:instanceId/broadcast — Lista broadcasts da instância
* GET /api/instances/:instanceId/broadcast/:id — Detalhe de um broadcast
* GET /api/inbox/avatar/:jid — Proxy de foto de perfil (evita CORS)
*
* Todas as rotas exigem autenticação (tenantId no req).
* O WhatsAppConnectionManager é injetado via buildSendRoutes() para
* acessar os sockets Baileys ativos.
*
* @see WhatsAppConnectionManager — provê getSocket() e getIO()
* @see ContactHandler — reconciliação de nomes (mesma lógica, mas via endpoint HTTP)
*/
import { Router, type Request, type Response } from 'express'
import { createHash } from 'crypto'
import { z } from 'zod'
import { prisma } from '../../infra/database/prisma'
import { dragonfly } from '../../infra/cache/dragonfly'
import { logger } from '../../config/logger'
import type { WhatsAppConnectionManager } from './connection/WhatsAppConnectionManager'
import { sendRichMessage } from './rich-message'
/**
* Constrói as rotas de envio e operações WhatsApp.
*
* Montadas em: /api/instances/:instanceId/
* Usa mergeParams para herdar :instanceId do router pai.
*/
export function buildSendRoutes(manager: WhatsAppConnectionManager): Router {
const router = Router({ mergeParams: true })
// ═══════════════════════════════════════════════════════════════════════════
// POST /api/instances/:instanceId/send — Envio de mensagem de texto
// ═══════════════════════════════════════════════════════════════════════════
/**
* Envia uma mensagem de texto para um JID (contato ou grupo).
*
* Body:
* - jid: string (obrigatório) — JID destino (ex: "5511999999999@s.whatsapp.net")
* - text: string (obrigatório) — Texto da mensagem (até 4096 chars)
* - replyToMessageId?: string — ID da mensagem sendo respondida (quote/reply)
* - mentions?: string[] — Lista de JIDs mencionados com @ (para grupos)
*
* Fluxo:
* 1. Valida instância conectada e socket disponível
* 2. Monta contexto de reply (se houver replyToMessageId)
* 3. Envia via Baileys sock.sendMessage()
* 4. Cria/atualiza Chat no banco
* 5. Persiste a mensagem enviada
* 6. Retorna 201 com o registro da mensagem
*/
router.post('/send', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const instanceId = req.params['instanceId'] as string
const {
jid,
text,
footer,
replyToMessageId,
mentions,
// ── Tipos ricos (InfiniteAPI/Baileys) ────────────────────────────────
nativeButtons,
nativeList,
nativeCarousel,
poll,
} = req.body as {
jid: string
text?: string
footer?: string
replyToMessageId?: string
mentions?: string[]
nativeButtons?: Array<{ type: string; id?: string; text: string; url?: string; copyText?: string; phoneNumber?: string }>
nativeList?: { buttonText: string; sections: Array<{ title: string; rows: Array<{ id: string; title: string; description?: string }> }> }
nativeCarousel?: { cards: Array<{ title?: string; body?: string; footer?: string; image?: { url: string }; buttons: Array<{ type?: string; id?: string; text: string }> }> }
poll?: { name: string; values: string[]; selectableCount?: number }
}
const hasRichContent = !!(nativeButtons || nativeList || nativeCarousel || poll)
if (!jid || (!text && !hasRichContent)) {
res.status(400).json({ error: 'jid e text (ou tipo rico) são obrigatórios' })
return
}
// Valida que a instância existe e está conectada
const instance = await prisma.instance.findFirst({
where: { id: instanceId, tenantId },
})
if (!instance || instance.status !== 'CONNECTED') {
res.status(400).json({ error: 'Instância não conectada' })
return
}
// Busca o socket Baileys ativo
const sock = manager.getSocket(instanceId)
if (!sock) {
res.status(503).json({ error: 'Socket não disponível' })
return
}
// ── Resolve o JID real via onWhatsApp (corrige o dígito 9 brasileiro) ────
// Números brasileiros migraram de 8 para 9 dígitos. O usuário pode digitar
// com ou sem o 9 — o WhatsApp sabe o JID correto. Grupos (@g.us) são
// excluídos pois já têm JID definitivo.
let resolvedJid = jid
if (!jid.endsWith('@g.us')) {
try {
const waResults = await sock.onWhatsApp(jid)
const waResult = waResults?.[0]
if (!waResult?.exists) {
res.status(422).json({ error: 'Número não encontrado no WhatsApp' })
return
}
// Se onWhatsApp retornar @lid (WhatsApp moderno), ignoramos — o Baileys
// resolve @lid internamente ao enviar. Armazenar @lid no banco criaria
// um chat duplicado conflitando com o @s.whatsapp.net do MessageHandler.
if (!waResult.jid.endsWith('@lid')) {
resolvedJid = waResult.jid // JID correto com ou sem o 9
}
} catch (err) {
logger.warn({ err, jid }, 'onWhatsApp falhou — usando JID original')
// Continua com o JID original se a consulta falhar
}
}
// Monta contexto de reply (quoted message) se necessário
// O Baileys precisa da mensagem original para exibir o quote no WhatsApp
let quotedMsg: any
if (replyToMessageId) {
const reply = await prisma.message.findUnique({
where: { instanceId_messageId: { instanceId, messageId: replyToMessageId } },
})
if (reply?.body) {
quotedMsg = {
key: { remoteJid: resolvedJid, fromMe: reply.fromMe, id: reply.messageId },
message: { conversation: reply.body },
}
}
}
// ── Mensagens ricas: delega ao rich-message builder ──────────────────────
// O @whiskeysockets/baileys padrão não suporta nativeButtons/nativeList/
// nativeCarousel. O builder converte para os protos corretos e usa
// relayMessage() para enviar sem passar pelo generateWAMessageContent.
const hasRich = !!(nativeButtons || nativeList || nativeCarousel || poll)
let sentMessageId: string
if (hasRich) {
sentMessageId = await sendRichMessage(
sock,
resolvedJid,
{ text, footer, nativeButtons, nativeList, nativeCarousel, poll } as any,
quotedMsg,
instance.engine,
)
} else {
// Texto simples (com suporte a @mentions)
const msgContent: any = { text: text ?? '' }
if (mentions?.length) msgContent.mentions = mentions
const sent = await sock.sendMessage(resolvedJid, msgContent, quotedMsg ? { quoted: quotedMsg } : undefined)
sentMessageId = sent?.key?.id ?? `text-${Date.now()}`
}
const sent = { key: { id: sentMessageId } }
// ── Persiste chat e mensagem imediatamente ────────────────────────────────
// O Baileys dispara messages.upsert com type='append' para mensagens enviadas
// por nós, o que não emite eventos Socket.IO. Por isso fazemos a criação aqui
// e emitimos os eventos manualmente para o frontend atualizar em tempo real.
const isGroup = resolvedJid.endsWith('@g.us')
// Garantir que o Contato existe no banco
const contact = await prisma.contact.upsert({
where: { tenantId_instanceId_jid: { tenantId, instanceId, jid: resolvedJid } },
create: {
tenantId,
instanceId,
jid: resolvedJid,
phone: isGroup ? null : resolvedJid.split('@')[0],
},
update: {},
})
const chat = await prisma.chat.upsert({
where: { tenantId_instanceId_jid: { tenantId, instanceId, jid: resolvedJid } },
create: { tenantId, instanceId, jid: resolvedJid, contactId: contact.id, lastMessageAt: new Date() },
update: { contactId: contact.id, lastMessageAt: new Date() },
})
const messageId = sent?.key.id ?? `local-${Date.now()}`
// Determina tipo, body e richPayload para persistência
let msgType: 'TEXT' | 'POLL' | 'BUTTONS' | 'LIST' | 'CAROUSEL' | 'INTERACTIVE' = 'TEXT'
let msgBody = text ?? ''
let richPayloadData: Record<string, unknown> | undefined
if (poll) {
msgType = 'POLL'
msgBody = poll.name
richPayloadData = { poll }
} else if (nativeButtons) {
// Detecta se há botões CTA (url/copy/call) → INTERACTIVE, senão → BUTTONS
const hasCta = nativeButtons.some((b: any) => ['url','copy','call'].includes(b.type))
msgType = hasCta ? 'INTERACTIVE' : 'BUTTONS'
msgBody = text ?? ''
richPayloadData = { text, footer, nativeButtons }
} else if (nativeList) {
msgType = 'LIST'
msgBody = text ?? nativeList.buttonText
richPayloadData = { text, footer, nativeList }
} else if (nativeCarousel) {
msgType = 'CAROUSEL'
msgBody = text ?? nativeCarousel.cards[0]?.title ?? '[Carrossel]'
richPayloadData = { text, footer, nativeCarousel }
}
const msg = await prisma.message.upsert({
where: { instanceId_messageId: { instanceId, messageId } },
create: {
tenantId,
instanceId,
chatId: chat.id,
remoteJid: resolvedJid,
messageId,
fromMe: true,
type: msgType,
body: msgBody,
richPayload: richPayloadData ? JSON.parse(JSON.stringify(richPayloadData)) : undefined,
status: 'SENT',
timestamp: new Date(),
},
update: {}, // já existe (eco do Baileys chegou primeiro) — não sobrescreve
})
// ── Emite eventos Socket.IO para o frontend atualizar em tempo real ───────
const io = manager.getIO()
// Inclui replyTo quando é uma resposta — necessário para exibir a caixa
// de citação em sessões adicionais do mesmo tenant que tenham o chat aberto.
const msgToEmit = replyToMessageId
? await prisma.message.findUnique({
where: { id: msg.id },
include: {
replyTo: {
select: { id: true, messageId: true, body: true, fromMe: true, type: true, mediaUrl: true },
},
},
}) ?? msg
: msg
// Atualiza o chat aberto (se o usuário já estiver nele)
io.to(`chat:${chat.id}`).emit('message:new', { ...msgToEmit, pushName: null, senderJid: null })
const displayName = isGroup
? (chat.name ?? null)
: (contact.name ?? contact.verifiedName ?? contact.notify ?? null)
// Atualiza a lista de chats do tenant (novo chat ou reordenar pelo lastMessageAt)
io.to(`tenant:${tenantId}`).emit('chat:upsert', {
id: chat.id,
tenantId: chat.tenantId,
instanceId: chat.instanceId,
jid: chat.jid,
name: chat.name ?? null,
unreadCount: chat.unreadCount,
lastMessageAt: chat.lastMessageAt,
isPinned: chat.isPinned,
isArchived: chat.isArchived,
botPaused: chat.botPaused,
contact: {
name: displayName,
phone: contact.phone,
avatarUrl: contact.avatarUrl,
scoreReputacao: contact.scoreReputacao,
flagRestricao: contact.flagRestricao,
},
lastMessage: {
body: msgBody,
fromMe: true,
status: 'SENT',
type: msgType,
timestamp: msg.timestamp,
},
})
res.status(201).json({ id: msg.id, messageId: msg.messageId })
})
// ═══════════════════════════════════════════════════════════════════════════
// GET /api/instances/:instanceId/groups/:groupJid/participants
// Lista participantes de um grupo (para autocomplete de @mentions)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Retorna a lista de participantes de um grupo WhatsApp.
*
* Usado pelo InputBar do frontend para autocomplete de @mentions.
* Busca metadata via Baileys groupMetadata() e enriquece com dados do banco:
* - Nome do contato (se existir no banco)
* - Para JIDs @lid sem nome: busca pushName da última mensagem no grupo
* - Telefone formatado
*
* Response: { groupJid, subject, participants: [{ jid, admin, name, phone }] }
*/
router.get('/groups/:groupJid/participants', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const instanceId = req.params['instanceId'] as string
const groupJid = req.params['groupJid'] as string
if (!groupJid?.endsWith('@g.us')) {
res.status(400).json({ error: 'JID de grupo inválido' })
return
}
const instance = await prisma.instance.findFirst({
where: { id: instanceId, tenantId },
})
if (!instance || instance.status !== 'CONNECTED') {
res.status(400).json({ error: 'Instância não conectada' })
return
}
const sock = manager.getSocket(instanceId)
if (!sock) {
res.status(503).json({ error: 'Socket não disponível' })
return
}
try {
// Busca metadata do grupo via Baileys (inclui lista de participantes)
const metadata = await sock.groupMetadata(groupJid)
const participants = await Promise.all(
metadata.participants.map(async (p) => {
// Enriquece com dados do banco (nome, telefone)
const contact = await prisma.contact.findFirst({
where: { tenantId, instanceId, jid: p.id },
select: { name: true, notify: true, phone: true },
})
let name = contact?.name || contact?.notify || null
let phone = contact?.phone || null
// Para @lid JIDs sem nome: tenta buscar pushName da última mensagem no grupo
// (LID não tem telefone no JID, então o nome só pode vir de mensagens)
if (!name && p.id.endsWith('@lid')) {
const lastMsg = await prisma.message.findFirst({
where: {
instanceId,
remoteJid: groupJid,
senderJid: p.id,
fromMe: false,
pushName: { not: null },
},
orderBy: { timestamp: 'desc' },
select: { pushName: true },
})
if (lastMsg?.pushName) name = lastMsg.pushName
}
// Extrai telefone do JID (apenas para @s.whatsapp.net, não @lid)
if (!phone) {
phone = p.id.endsWith('@lid') ? null : p.id.split('@')[0].split(':')[0]
}
return {
jid: p.id,
admin: p.admin ?? null, // 'admin' | 'superadmin' | null
name,
phone,
}
})
)
res.json({ groupJid, subject: metadata.subject, participants })
} catch (err) {
logger.error({ err, groupJid }, '[Groups] Erro ao buscar participantes')
res.status(500).json({ error: 'Erro ao buscar participantes do grupo' })
}
})
// ═══════════════════════════════════════════════════════════════════════════
// POST /api/instances/:instanceId/reconcile-names
// Reconciliação bulk de nomes via pushName (endpoint HTTP)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Reconcilia nomes de contatos usando pushName das mensagens recebidas.
*
* Diferente de ContactHandler.reconcileNamesFromMessages() que roda no sync,
* este endpoint pode ser chamado pelo frontend a qualquer momento (ex: page reload).
*
* Implementação:
* - Usa raw SQL com DISTINCT ON para buscar o pushName mais recente por JID
* - Uma única query UPDATE...FROM (subquery) atualiza todos os contatos sem nome
* - Cache DragonflyDB de 30min para evitar re-execução em loop
* - Idempotente: se já executou nos últimos 30min, retorna 204
*
* NOTA sobre tabelas: Prisma usa nomes como "Contact" e "Message" nos models,
* mas as tabelas PostgreSQL reais são "contacts" e "messages" (via @@map).
* No $executeRaw, SEMPRE usar os nomes reais das tabelas.
*/
// Fallback in-memory para o TTL de reconciliação quando Redis está fora
// Map<cacheKey, timestampMs do último reconcile>
const reconcileMemCache = new Map<string, number>()
const RECONCILE_TTL_MS = 30 * 60 * 1000 // 30 min
router.post('/reconcile-names', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const instanceId = req.params['instanceId'] as string
const cacheKey = `reconcile:${tenantId}:${instanceId}`
// Camada 1: Redis (30min TTL)
const cached = await dragonfly.get(cacheKey)
if (cached) { res.status(204).end(); return }
// Camada 2: in-memory (protege quando Redis está fora)
const memTs = reconcileMemCache.get(cacheKey)
if (memTs && Date.now() - memTs < RECONCILE_TTL_MS) {
res.status(204).end()
return
}
try {
// Query bulk: encontra contatos sem nome que têm msgs recebidas com pushName.
// Usa DISTINCT ON (PostgreSQL) para pegar o pushName mais recente por JID.
// Uma única query UPDATE...FROM atualiza todos — O(1) round-trips ao banco.
const updated = await prisma.$executeRaw`
UPDATE contacts c
SET
"name" = sub."pushName",
"notify" = COALESCE(c."notify", sub."pushName"),
"updatedAt" = NOW()
FROM (
SELECT DISTINCT ON (m."remoteJid")
m."remoteJid",
m."pushName"
FROM messages m
WHERE m."instanceId" = ${instanceId}
AND m."fromMe" = false
AND m."pushName" IS NOT NULL
AND m."pushName" != ''
ORDER BY m."remoteJid", m."timestamp" DESC
) sub
WHERE c."instanceId" = ${instanceId}
AND c."tenantId" = ${tenantId}
AND c."jid" = sub."remoteJid"
AND c."name" IS NULL
AND c."notify" IS NULL
`
// Seta flag no Redis (30min) + in-memory — independente do resultado
await dragonfly.set(cacheKey, String(updated), 1800)
reconcileMemCache.set(cacheKey, Date.now())
if (updated > 0) {
logger.info({ instanceId, updated }, '[Reconcile] Nomes atualizados via pushName')
// Notifica frontend para re-buscar a lista de chats
const io = manager.getIO()
if (io) {
io.to(`tenant:${tenantId}`).emit('chats:refresh', { instanceId, reason: 'reconcile', updated })
}
}
res.json({ updated })
} catch (err) {
logger.error({ err, instanceId }, '[Reconcile] Erro na reconciliação bulk')
// Mesmo com erro, seta cache curto (60s) para não ficar retentando em loop
await dragonfly.set(cacheKey, '0', 60).catch(() => {})
res.status(500).json({ error: 'Erro na reconciliação' })
}
})
// ═══════════════════════════════════════════════════════════════════════════
// POST /api/instances/:instanceId/broadcast — Disparo em massa
// ═══════════════════════════════════════════════════════════════════════════
/**
* Inicia um disparo em massa (broadcast) para até 500 números.
*
* Validação:
* - numbers: array de strings (mínimo 5 chars cada, 1-500 itens)
* - message: texto (1-4096 chars)
* - delayMs: delay entre envios (500-60000ms, default 2000ms)
*
* Fluxo:
* 1. Valida payload via Zod
* 2. Cria registro Broadcast com status 'RUNNING'
* 3. Retorna 202 com jobId imediatamente
* 4. Processa envios em background (async IIFE)
* 5. Atualiza sentCount/failedCount a cada envio
* 6. Marca como 'DONE' ao finalizar
*
* IMPORTANTE: O delay entre envios é obrigatório para evitar ban do WhatsApp.
* O default de 2s é conservador — ajuste conforme volume e tolerância ao risco.
*/
router.post('/broadcast', async (req: Request, res: Response) => {
const schema = z.object({
numbers: z.array(z.string().min(5)).min(1).max(500),
message: z.string().min(1).max(4096),
delayMs: z.number().int().min(500).max(60000).default(2000),
})
let body: z.infer<typeof schema>
try {
body = schema.parse(req.body)
} catch (err: any) {
res.status(400).json({ error: err.errors ?? err.message }); return
}
const tenantId = req.tenantId!
const instanceId = req.params['instanceId'] as string
const instance = await prisma.instance.findFirst({ where: { id: instanceId, tenantId } })
if (!instance || instance.status !== 'CONNECTED') {
res.status(400).json({ error: 'Instância não conectada' }); return
}
const sock = manager.getSocket(instanceId)
if (!sock) {
res.status(503).json({ error: 'Socket não disponível' }); return
}
// Cria registro do broadcast no banco
const broadcast = await prisma.broadcast.create({
data: {
tenantId,
instanceId,
message: body.message,
totalCount: body.numbers.length,
sentCount: 0,
failedCount: 0,
status: 'RUNNING',
},
})
// Retorna imediatamente (processamento assíncrono)
res.status(202).json({ jobId: broadcast.id, total: body.numbers.length })
// ── Processamento em background ─────────────────────────────────────
;(async () => {
let sent = 0, failed = 0
for (let i = 0; i < body.numbers.length; i++) {
const rawNumber = body.numbers[i]!
// Normaliza número: remove caracteres não-numéricos
const number = rawNumber.replace(/\D/g, '')
// Se já é um JID completo (contém @), usa como está; senão, adiciona sufixo
const jid = number.includes('@') ? number : `${number}@s.whatsapp.net`
try {
await sock.sendMessage(jid, { text: body.message })
await prisma.broadcastRecipient.create({
data: { broadcastId: broadcast.id, jid, status: 'SENT' },
})
sent++
} catch (err) {
logger.warn({ jid, err }, '[Broadcast] Falha ao enviar')
await prisma.broadcastRecipient.create({
data: { broadcastId: broadcast.id, jid, status: 'FAILED' },
}).catch(() => {})
failed++
}
// Atualiza contadores em tempo real (permite consulta de progresso)
await prisma.broadcast.update({
where: { id: broadcast.id },
data: { sentCount: sent, failedCount: failed },
})
// Delay entre envios para evitar ban do WhatsApp (pula no último item)
if (i < body.numbers.length - 1) {
await new Promise(r => setTimeout(r, body.delayMs))
}
}
// Marca como concluído
await prisma.broadcast.update({
where: { id: broadcast.id },
data: { status: 'DONE', finishedAt: new Date() },
})
})().catch(err => {
logger.error({ err }, '[Broadcast] Erro no job')
prisma.broadcast.update({ where: { id: broadcast.id }, data: { status: 'FAILED' } }).catch(() => {})
})
})
// ═══════════════════════════════════════════════════════════════════════════
// GET /api/instances/:instanceId/broadcast — Lista broadcasts
// ═══════════════════════════════════════════════════════════════════════════
/** Lista os últimos broadcasts da instância (paginado, max 50) */
router.get('/broadcast', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const instanceId = req.params['instanceId'] as string
const limit = Math.min(Number(req.query['limit'] ?? 20), 50)
const rows = await prisma.broadcast.findMany({
where: { tenantId, instanceId },
orderBy: { createdAt: 'desc' },
take: limit,
include: { _count: { select: { recipients: true } } },
})
res.json(rows)
})
// ═══════════════════════════════════════════════════════════════════════════
// GET /api/instances/:instanceId/broadcast/:id — Detalhe de broadcast
// ═══════════════════════════════════════════════════════════════════════════
/** Retorna detalhe de um broadcast específico, incluindo lista de destinatários */
router.get('/broadcast/:broadcastId', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const broadcastId = req.params['broadcastId'] as string
const row = await prisma.broadcast.findFirst({
where: { id: broadcastId, tenantId },
include: { recipients: { orderBy: { createdAt: 'asc' } } },
})
if (!row) { res.status(404).json({ error: 'Não encontrado' }); return }
res.json(row)
})
return router
}
// ═══════════════════════════════════════════════════════════════════════════════
// ROTA DE AVATAR — Proxy de foto de perfil (evita CORS)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Constrói a rota de proxy de avatar.
*
* Montada em: /api/inbox/avatar/:jid
*
* O frontend não pode buscar avatares diretamente do CDN do WhatsApp (CORS).
* Esta rota age como proxy:
* 1. Busca avatarUrl salvo no banco (cache DB)
* 2. Se URL da DB expirou, busca via Baileys profilePictureUrl
* 3. Faz proxy do buffer (download → response) para evitar redirect/CORS
* 4. Salva URL nova no banco para próximas requisições
*
* Retorna 204 (sem conteúdo) quando avatar não disponível:
* - Foto de perfil privada
* - JID @lid (não suporta profilePictureUrl)
* - Socket não disponível
* O frontend trata 204 mostrando iniciais em vez da foto.
*
* Cache HTTP: max-age=86400 (24h) + stale-while-revalidate=604800 (7d) para
* avatares encontrados; 300s para 204. Valida revalidação via ETag.
*
* Negative cache: quando decide 204, grava `avatar:none:*` no Dragonfly
* com TTL de 1h — próximos hits respondem sem tocar no Prisma nem no Baileys.
*/
export function buildAvatarRoute(manager: WhatsAppConnectionManager): Router {
const router = Router()
// Cabeçalho longo com stale-while-revalidate: o browser serve do cache
// local por 24h e, entre 24h e 7d, usa o stale enquanto revalida em
// background. Corta requisições repetidas na navegação normal.
const HIT_CACHE_CONTROL = 'public, max-age=86400, stale-while-revalidate=604800'
const MISS_CACHE_CONTROL = 'public, max-age=300'
const NO_AVATAR_TTL_SECONDS = 3600 // 1h
const noAvatarKey = (tenantId: string, instanceId: string, jid: string) =>
`avatar:none:${tenantId}:${instanceId}:${jid}`
// ETag baseado no avatarUrl conhecido. É determinístico (hash da string),
// logo quando a URL do banco muda — sinal de que a foto mudou — o ETag
// também muda e força o browser a buscar bytes novos.
const etagFor = (urlOrMarker: string) =>
`"${createHash('md5').update(urlOrMarker).digest('hex')}"`
router.get('/avatar/:jid', async (req: Request, res: Response) => {
const jid = req.params['jid'] as string
const instanceId = req.query['instance'] as string | undefined
const t0 = Date.now()
const tenantId = req.tenantId!
// Helper: retorna 204 No Content (não 404) para que o browser não logue
// erro no console. O <img onError> do frontend detecta a falta de imagem
// e mostra iniciais. Também grava no negative cache para curto-circuitar
// as próximas chamadas ao mesmo JID.
const noAvatar = (persist = true) => {
if (persist && instanceId) {
dragonfly
.set(noAvatarKey(tenantId, instanceId, jid), '1', NO_AVATAR_TTL_SECONDS)
.catch(() => {})
}
res.set('Cache-Control', MISS_CACHE_CONTROL).status(204).end()
}
if (!instanceId) {
res.set('Cache-Control', MISS_CACHE_CONTROL).status(204).end()
return
}
// 0. Negative cache: se sabemos que não tem avatar há menos de 1h, corta
// antes de tocar Prisma/Baileys (o backend está sob pressão de memória).
try {
if (await dragonfly.exists(noAvatarKey(tenantId, instanceId, jid))) {
res.set('Cache-Control', MISS_CACHE_CONTROL).status(204).end()
logger.debug({ jid, ms: Date.now() - t0, source: 'neg_cache' }, '[Avatar] 204 cached')
return
}
} catch {
// Dragonfly indisponível — segue o fluxo normal sem quebrar
}
// 1. Tenta avatarUrl já salvo no banco (funciona para todos os tipos de JID)
const contact = await prisma.contact.findFirst({
where: { tenantId, instanceId, jid },
select: { avatarUrl: true },
})
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)
if (req.headers['if-none-match'] === etag) {
res.set('ETag', etag)
res.set('Cache-Control', HIT_CACHE_CONTROL)
res.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
}
}
// 2. JIDs @lid não suportam profilePictureUrl no Baileys (retorna 404 no WA)
if (jid.endsWith('@lid')) { noAvatar(); return }
// 3. Busca URL fresca via Baileys profilePictureUrl
const sock = manager.getSocket(instanceId)
if (!sock) { noAvatar(); return }
try {
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)
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(() => {})
dragonfly.del(noAvatarKey(tenantId, instanceId, jid)).catch(() => {})
} catch {
noAvatar()
}
})
return router
}