"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 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 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; }