chore: initial setup with PostgreSQL, DragonflyDB, and Nginx routing configs

This commit is contained in:
Antigravity AI Agent
2026-05-13 05:12:32 +02:00
commit 19dbbae8b8
551 changed files with 308309 additions and 0 deletions
+740
View File
@@ -0,0 +1,740 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildSendRoutes = buildSendRoutes;
exports.buildAvatarRoute = buildAvatarRoute;
/**
* 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)
*/
const express_1 = require("express");
const crypto_1 = require("crypto");
const zod_1 = require("zod");
const prisma_1 = require("../../infra/database/prisma");
const dragonfly_1 = require("../../infra/cache/dragonfly");
const logger_1 = require("../../config/logger");
const StorageProvider_1 = require("../../core/StorageProvider");
const rich_message_1 = require("./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.
*/
function buildSendRoutes(manager) {
const router = (0, express_1.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, res) => {
const tenantId = req.tenantId;
const instanceId = req.params['instanceId'];
const { jid, text, footer, replyToMessageId, mentions,
// ── Tipos ricos (InfiniteAPI/Baileys) ────────────────────────────────
nativeButtons, nativeList, nativeCarousel, poll, } = req.body;
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_1.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_1.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;
if (replyToMessageId) {
const reply = await prisma_1.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;
if (hasRich) {
sentMessageId = await (0, rich_message_1.sendRichMessage)(sock, resolvedJid, { text, footer, nativeButtons, nativeList, nativeCarousel, poll }, quotedMsg, instance.engine);
}
else {
// Texto simples (com suporte a @mentions)
const msgContent = { 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_1.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_1.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';
let msgBody = text ?? '';
let richPayloadData;
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) => ['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_1.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_1.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, res) => {
const tenantId = req.tenantId;
const instanceId = req.params['instanceId'];
const groupJid = req.params['groupJid'];
if (!groupJid?.endsWith('@g.us')) {
res.status(400).json({ error: 'JID de grupo inválido' });
return;
}
const instance = await prisma_1.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_1.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_1.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_1.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();
const RECONCILE_TTL_MS = 30 * 60 * 1000; // 30 min
router.post('/reconcile-names', async (req, res) => {
const tenantId = req.tenantId;
const instanceId = req.params['instanceId'];
const cacheKey = `reconcile:${tenantId}:${instanceId}`;
// Camada 1: Redis (30min TTL)
const cached = await dragonfly_1.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_1.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_1.dragonfly.set(cacheKey, String(updated), 1800);
reconcileMemCache.set(cacheKey, Date.now());
if (updated > 0) {
logger_1.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_1.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_1.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, res) => {
const schema = zod_1.z.object({
numbers: zod_1.z.array(zod_1.z.string().min(5)).min(1).max(500),
message: zod_1.z.string().min(1).max(4096),
delayMs: zod_1.z.number().int().min(500).max(60000).default(2000),
});
let body;
try {
body = schema.parse(req.body);
}
catch (err) {
res.status(400).json({ error: err.errors ?? err.message });
return;
}
const tenantId = req.tenantId;
const instanceId = req.params['instanceId'];
const instance = await prisma_1.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_1.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 });
(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_1.prisma.broadcastRecipient.create({
data: { broadcastId: broadcast.id, jid, status: 'SENT' },
});
sent++;
}
catch (err) {
logger_1.logger.warn({ jid, err }, '[Broadcast] Falha ao enviar');
await prisma_1.prisma.broadcastRecipient.create({
data: { broadcastId: broadcast.id, jid, status: 'FAILED' },
}).catch(() => { });
failed++;
}
// Atualiza contadores em tempo real (permite consulta de progresso)
await prisma_1.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_1.prisma.broadcast.update({
where: { id: broadcast.id },
data: { status: 'DONE', finishedAt: new Date() },
});
})().catch(err => {
logger_1.logger.error({ err }, '[Broadcast] Erro no job');
prisma_1.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, res) => {
const tenantId = req.tenantId;
const instanceId = req.params['instanceId'];
const limit = Math.min(Number(req.query['limit'] ?? 20), 50);
const rows = await prisma_1.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, res) => {
const tenantId = req.tenantId;
const broadcastId = req.params['broadcastId'];
const row = await prisma_1.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.
*/
function buildAvatarRoute(manager) {
const router = (0, express_1.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, instanceId, jid) => `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) => `"${(0, crypto_1.createHash)('md5').update(urlOrMarker).digest('hex')}"`;
router.get('/avatar/:jid', async (req, res) => {
const jid = req.params['jid'];
const instanceId = req.query['instance'];
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_1.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_1.dragonfly.exists(noAvatarKey(tenantId, instanceId, jid))) {
res.set('Cache-Control', MISS_CACHE_CONTROL).status(204).end();
logger_1.logger.debug({ jid, ms: Date.now() - t0, source: 'neg_cache' }, '[Avatar] 204 cached');
return;
}
}
catch {
// 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, iid, j) => `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) => {
if (!StorageProvider_1.storageProvider.isRegistered())
return;
const wasabiPath = avatarWasabiPath(tenantId, instanceId, jid);
StorageProvider_1.storageProvider.uploadFile({
category: 'whatsapp',
customPath: wasabiPath,
file: { originalname: `${jid.replace(/[^a-zA-Z0-9]/g, '_')}.jpg`, buffer, mimetype: 'image/jpeg' },
}).then(() => prisma_1.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_1.prisma.contact.findFirst({
where: { tenantId, instanceId, jid },
select: { avatarUrl: true },
});
if (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).set('Cache-Control', HIT_CACHE_CONTROL).status(304).end();
logger_1.logger.debug({ jid, ms: Date.now() - t0, source: 'etag_304' }, '[Avatar] 304');
return;
}
if (isWasabi) {
// Já está no Wasabi — serve direto sem tocar no CDN do WhatsApp
try {
const buffer = await StorageProvider_1.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_1.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_1.logger.debug({ jid, ms: Date.now() - t0, source: 'db_cache' }, '[Avatar] served');
persistToWasabi(buffer);
return;
}
// URL expirou — cai no fallback Baileys abaixo
logger_1.logger.debug({ jid, ms: Date.now() - t0 }, '[Avatar] DB URL expirada, fallback Baileys');
}
catch { /* Erro de rede — cai no fallback 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;
}
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')
.set('Cache-Control', HIT_CACHE_CONTROL)
.set('ETag', etagFor(url))
.set('Content-Length', String(buffer.length))
.send(buffer);
logger_1.logger.debug({ jid, ms: Date.now() - t0, source: 'baileys' }, '[Avatar] served');
// Persiste no Wasabi (fire-and-forget) e invalida negative cache
persistToWasabi(buffer);
dragonfly_1.dragonfly.del(noAvatarKey(tenantId, instanceId, jid)).catch(() => { });
}
catch {
noAvatar();
}
});
return router;
}