741 lines
38 KiB
JavaScript
741 lines
38 KiB
JavaScript
"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;
|
|
}
|