1125 lines
63 KiB
JavaScript
1125 lines
63 KiB
JavaScript
"use strict";
|
||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||
};
|
||
Object.defineProperty(exports, "__esModule", { value: true });
|
||
exports.WhatsAppConnectionManager = void 0;
|
||
/**
|
||
* WhatsAppConnectionManager — Orquestra conexões Baileys para todas as instâncias.
|
||
*
|
||
* Esta é a classe central do módulo WhatsApp. Ela gerencia:
|
||
* 1. Ciclo de vida das conexões (connect, disconnect, reconnect)
|
||
* 2. Registro de TODOS os event listeners do Baileys
|
||
* 3. Reconexão automática com backoff exponencial
|
||
* 4. Watchdog para detectar conexões mortas silenciosamente
|
||
* 5. Sincronização de chats, contatos e histórico
|
||
* 6. Mapeamento LID → phone JID (via chats.phoneNumberShare)
|
||
* 7. Emissão de eventos Socket.IO para o frontend
|
||
*
|
||
* ARQUITETURA:
|
||
* - Uma instância de WhatsAppConnectionManager por servidor (singleton prático)
|
||
* - Cada instância WhatsApp (número conectado) tem seu próprio WASocket
|
||
* - Os Maps internos indexam por instanceId para acesso O(1)
|
||
* - Multi-tenant: tenantByInstance mapeia instância → tenant para isolamento
|
||
*
|
||
* EVENTOS BAILEYS REGISTRADOS:
|
||
* - creds.update → salva credenciais (auth state)
|
||
* - connection.update → gerencia QR, conexão, desconexão, reconexão
|
||
* - messages.upsert → delega para MessageHandler (tempo real + histórico)
|
||
* - messages.update → atualiza status (sent→delivered→read)
|
||
* - contacts.upsert/update → delega para ContactHandler
|
||
* - chats.upsert/update → sincroniza lista de chats no banco
|
||
* - chats.phoneNumberShare → captura mapeamento LID → phone JID
|
||
* - groups.upsert/update → sincroniza grupos (subject, metadata)
|
||
* - messaging-history.set → history sync (contatos, chats, mensagens em lote)
|
||
*
|
||
* @see MessageHandler — processa mensagens individuais
|
||
* @see ContactHandler — gerencia contatos e avatares
|
||
*/
|
||
const path_1 = __importDefault(require("path"));
|
||
const promises_1 = __importDefault(require("fs/promises"));
|
||
const promises_2 = __importDefault(require("dns/promises"));
|
||
const baileys_1 = require("@whiskeysockets/baileys");
|
||
const qrcode_1 = __importDefault(require("qrcode"));
|
||
const env_1 = require("../../../config/env");
|
||
const logger_1 = require("../../../config/logger");
|
||
const dragonfly_1 = require("../../../infra/cache/dragonfly");
|
||
const prisma_1 = require("../../../infra/database/prisma");
|
||
const client_1 = require("@prisma/client");
|
||
const MessageHandler_1 = require("../handlers/MessageHandler");
|
||
const ContactHandler_1 = require("../handlers/ContactHandler");
|
||
const chatbot_service_1 = require("../../chatbot/chatbot.service");
|
||
const hook_bus_1 = require("../../../core/hook-bus");
|
||
// ─── Chaves de cache no DragonflyDB (Redis-compatible) ──────────────────────
|
||
const CACHE_KEY_STATUS = (instanceId) => `instance:${instanceId}:status`;
|
||
const CACHE_KEY_QR = (instanceId) => `instance:${instanceId}:qr`;
|
||
// ─── Configuração de reconexão automática ───────────────────────────────────
|
||
// Backoff exponencial: 3s → 6s → 12s → 24s → 48s → ... → max 5min
|
||
// Nunca desiste, exceto loggedOut (usuário deslogou) ou forbidden (ban)
|
||
const RECONNECT_BASE_DELAY_MS = 3000; // delay inicial
|
||
const RECONNECT_MAX_DELAY_MS = 300000; // teto geral: 5 minutos
|
||
// Teto MENOR para erros transientes de rede (DNS, ECONN, ETIMEDOUT).
|
||
// Esses erros costumam resolver em segundos; um teto de 5min deixa a
|
||
// sessão "dormindo" num setTimeout longo enquanto a rede já voltou.
|
||
const RECONNECT_NETWORK_MAX_MS = 60000; // teto rede: 60s
|
||
const RECONNECT_MAX_ATTEMPTS = Infinity;
|
||
// ─── Network probe — acorda reconexões pendentes quando a rede volta ────────
|
||
// Enquanto uma instância está aguardando backoff, um DNS lookup leve é
|
||
// disparado em intervalo fixo. Se o lookup funciona E existe um retry
|
||
// agendado para daqui a mais de PROBE_ADVANCE_THRESHOLD_MS, cancela o timer
|
||
// e dispara retry imediato. Zero tráfego para o WhatsApp — só DNS.
|
||
const NETWORK_PROBE_INTERVAL_MS = 30000; // a cada 30s
|
||
const PROBE_ADVANCE_THRESHOLD_MS = 15000; // só adianta se faltar > 15s
|
||
const PROBE_DNS_HOST = 'web.whatsapp.com';
|
||
// ─── Watchdog: detecta conexão morta silenciosamente ────────────────────────
|
||
// Conservador por design: reconexões frequentes sinalizam "cliente instável"
|
||
// para o WhatsApp e aumentam risco de banimento do número. Por isso:
|
||
// - Timeout alto (10 min): conversas calmas são normais, não disparam
|
||
// - Intervalo baixo de verificação (3 min): menos overhead
|
||
// - Ping leve antes de matar: tenta reviver sem derrubar o socket
|
||
// - Cool-down entre kills: evita loop de reconexão se a rede oscila
|
||
const WATCHDOG_INTERVAL_MS = 180000; // verifica a cada 3 min
|
||
const WATCHDOG_TIMEOUT_MS = 600000; // 10 min sem atividade antes de agir
|
||
const WATCHDOG_PING_GRACE_MS = 30000; // aguarda 30s após ping antes de matar
|
||
const WATCHDOG_COOLDOWN_MS = 900000; // 15 min entre force-kills consecutivos
|
||
class WhatsAppConnectionManager {
|
||
constructor(io) {
|
||
// ─── Maps indexados por instanceId (UUID) ──────────────────────────────
|
||
/** Socket Baileys ativo por instância */
|
||
this.sockets = new Map();
|
||
/** Handler de contatos por instância */
|
||
this.contactHandlers = new Map();
|
||
/** Handler de mensagens por instância */
|
||
this.msgHandlers = new Map();
|
||
/** Contador de tentativas de reconexão (reseta ao conectar com sucesso) */
|
||
this.reconnectAttempts = new Map();
|
||
/** Timer de reconexão pendente (clearTimeout para cancelar) */
|
||
this.reconnectTimers = new Map();
|
||
/**
|
||
* Timestamp absoluto (ms) em que o retry pendente vai disparar.
|
||
* Usado pelo network probe para decidir se vale adiantar um retry
|
||
* que ainda está dormindo num backoff longo.
|
||
*/
|
||
this.reconnectScheduledAt = new Map();
|
||
/** Timer global do network probe (singleton) */
|
||
this.networkProbeTimer = null;
|
||
/** Timer do watchdog (clearInterval para parar) */
|
||
this.watchdogTimers = new Map();
|
||
/** Timestamp da última atividade (qualquer evento Baileys) */
|
||
this.lastActivity = new Map();
|
||
/** Timestamp do último force-kill do watchdog (cool-down anti-loop) */
|
||
this.lastWatchdogKill = new Map();
|
||
/** Timestamp do último ping do watchdog (aguarda resposta antes de matar) */
|
||
this.lastWatchdogPing = new Map();
|
||
/** Mapeamento instanceId → tenantId (necessário para reconexões) */
|
||
this.tenantByInstance = new Map();
|
||
this.io = io;
|
||
this.chatbotService = new chatbot_service_1.ChatbotService(io);
|
||
this.startNetworkProbe();
|
||
}
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// CONEXÃO PRINCIPAL
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
/**
|
||
* Conecta (ou reconecta) uma instância WhatsApp.
|
||
*
|
||
* Fluxo:
|
||
* 1. Carrega auth state do disco (useMultiFileAuthState)
|
||
* 2. Cria socket Baileys com configuração padrão
|
||
* 3. Registra TODOS os event listeners
|
||
* 4. Baileys inicia handshake com WhatsApp Web
|
||
* 5. Se precisar de QR: emite evento 'qr' via Socket.IO
|
||
* 6. Se já autenticado: connection 'open' dispara sync
|
||
*
|
||
* IMPORTANTE: Este método é idempotente — se a instância já está
|
||
* conectada, retorna sem fazer nada (evita sockets duplicados).
|
||
*/
|
||
async connect(instanceId, tenantId) {
|
||
if (this.sockets.has(instanceId)) {
|
||
// Socket já existe — re-emite o status atual para sincronizar o frontend
|
||
// (acontece quando o backend reconectou em background antes do frontend abrir)
|
||
const cachedStatus = await dragonfly_1.dragonfly.get(CACHE_KEY_STATUS(instanceId));
|
||
const currentStatus = cachedStatus ?? client_1.InstanceStatus.CONNECTED;
|
||
logger_1.logger.info({ instanceId, currentStatus }, 'Instância já conectada — re-emitindo status para o frontend');
|
||
this.io.to(`tenant:${tenantId}`).emit('instance:status', {
|
||
instanceId,
|
||
status: currentStatus,
|
||
});
|
||
return;
|
||
}
|
||
// Limpa timers pendentes de reconexão anterior
|
||
this.clearReconnectTimer(instanceId);
|
||
this.tenantByInstance.set(instanceId, tenantId);
|
||
// ── Auth state: credenciais persistidas em disco ──────────────────────
|
||
// Cada instância tem seu diretório em BAILEYS_SESSIONS_PATH/{instanceId}/
|
||
// Contém: creds.json, app-state-sync-key-*, pre-key-*, etc.
|
||
const sessionDir = path_1.default.resolve(env_1.env.BAILEYS_SESSIONS_PATH, instanceId);
|
||
try {
|
||
await promises_1.default.mkdir(sessionDir, { recursive: true });
|
||
}
|
||
catch (err) {
|
||
logger_1.logger.error({ err, instanceId, sessionDir }, 'Falha ao criar diretório de sessão');
|
||
await this.updateInstanceStatus(instanceId, client_1.InstanceStatus.DISCONNECTED);
|
||
this.io.to(`tenant:${tenantId}`).emit('instance:status', { instanceId, status: client_1.InstanceStatus.DISCONNECTED });
|
||
return;
|
||
}
|
||
const { state, saveCreds } = await (0, baileys_1.useMultiFileAuthState)(sessionDir);
|
||
// fetchLatestBaileysVersion faz requisição HTTP ao WhatsApp — pode falhar
|
||
// se não houver internet. Usa versão embutida como fallback seguro.
|
||
let version = [2, 3000, 1023504162];
|
||
try {
|
||
const fetched = await (0, baileys_1.fetchLatestBaileysVersion)();
|
||
version = fetched.version;
|
||
}
|
||
catch (err) {
|
||
logger_1.logger.warn({ instanceId }, 'fetchLatestBaileysVersion falhou — usando versão embutida como fallback');
|
||
}
|
||
logger_1.logger.info({ instanceId, version }, 'Iniciando socket Baileys');
|
||
// ── Cria socket Baileys ──────────────────────────────────────────────
|
||
// DEBUG: logger trace só para capturar XML de mensagens enviadas
|
||
const debugLogger = {
|
||
level: 'trace',
|
||
child: () => debugLogger,
|
||
trace: (obj) => {
|
||
if (obj?.xml && typeof obj.xml === 'string' && obj.xml.startsWith('<message')) {
|
||
console.log('[NEWWHATS/STANZA-XML]\n' + obj.xml);
|
||
}
|
||
},
|
||
debug: () => { }, info: () => { }, warn: () => { }, error: () => { }, fatal: () => { },
|
||
};
|
||
const sock = (0, baileys_1.makeWASocket)({
|
||
version,
|
||
auth: {
|
||
creds: state.creds,
|
||
// Cache de chaves Signal para performance (evita leituras de disco)
|
||
keys: (0, baileys_1.makeCacheableSignalKeyStore)(state.keys, logger_1.logger),
|
||
},
|
||
printQRInTerminal: false,
|
||
qrTimeout: 60000,
|
||
logger: debugLogger,
|
||
browser: ['NewWhats', 'Chrome', '124.0.0'],
|
||
syncFullHistory: true, // Solicita histórico completo ao telefone
|
||
markOnlineOnConnect: true, // Marca como "online" ao conectar
|
||
// getMessage: callback para reenvio de mensagens (retry)
|
||
// O Baileys chama quando precisa reenviar uma mensagem que falhou
|
||
getMessage: async (key) => {
|
||
if (!key.id)
|
||
return undefined;
|
||
const msg = await prisma_1.prisma.message.findUnique({
|
||
where: { instanceId_messageId: { instanceId, messageId: key.id } },
|
||
});
|
||
if (!msg?.body)
|
||
return undefined;
|
||
return { conversation: msg.body };
|
||
},
|
||
});
|
||
this.sockets.set(instanceId, sock);
|
||
this.touchActivity(instanceId);
|
||
// ═════════════════════════════════════════════════════════════════════
|
||
// REGISTRO DE EVENT LISTENERS DO BAILEYS
|
||
// ═════════════════════════════════════════════════════════════════════
|
||
// ─── Credenciais: salvar ao atualizar ────────────────────────────────
|
||
// O Baileys atualiza credenciais periodicamente (rotação de chaves Signal)
|
||
sock.ev.on('creds.update', saveCreds);
|
||
// ─── Estado de conexão ───────────────────────────────────────────────
|
||
// Eventos: qr (novo QR gerado), open (conectado), close (desconectado),
|
||
// connecting (tentando conectar)
|
||
sock.ev.on('connection.update', (update) => {
|
||
this.touchActivity(instanceId);
|
||
this.baileysLog(tenantId, instanceId, 'connection.update', {
|
||
connection: update.connection,
|
||
qr: update.qr ? '(qr gerado)' : undefined,
|
||
lastDisconnect: update.lastDisconnect
|
||
? { statusCode: update.lastDisconnect.error?.output?.statusCode }
|
||
: undefined,
|
||
});
|
||
this.handleConnectionUpdate(instanceId, tenantId, update, sock);
|
||
});
|
||
// ─── Mensagens recebidas (tempo real + histórico) ────────────────────
|
||
// type 'notify': mensagem em tempo real → processMessage completo
|
||
// type 'append': histórico sync → handleHistory (bulk, sem socket)
|
||
const msgHandler = new MessageHandler_1.MessageHandler(sock, instanceId, tenantId, this.io, this.chatbotService);
|
||
this.msgHandlers.set(instanceId, msgHandler);
|
||
sock.ws.on('CB:message', (node) => {
|
||
const msgId = node?.attrs?.id;
|
||
const peerRecipientPn = node?.attrs?.peer_recipient_pn;
|
||
if (msgId && peerRecipientPn) {
|
||
msgHandler.setPeerRecipientHint(msgId, peerRecipientPn);
|
||
}
|
||
});
|
||
sock.ev.on('messages.upsert', (m) => {
|
||
this.touchActivity(instanceId);
|
||
// Log de debug para monitoramento via página /baileys
|
||
for (const msg of m.messages ?? []) {
|
||
const text = msg.message?.conversation ??
|
||
msg.message?.extendedTextMessage?.text ??
|
||
msg.message?.imageMessage?.caption ??
|
||
msg.message?.videoMessage?.caption ??
|
||
null;
|
||
this.baileysLog(tenantId, instanceId, 'message', {
|
||
fromMe: msg.key?.fromMe,
|
||
remoteJid: msg.key?.remoteJid,
|
||
body: text,
|
||
type: Object.keys(msg.message ?? {}).find(k => k !== 'messageContextInfo') ?? 'unknown',
|
||
status: msg.status,
|
||
});
|
||
// Log de monitoramento para mensagens LID
|
||
// Ajuda a verificar se key.senderPn está presente para resolução
|
||
if (msg.key?.remoteJid?.endsWith('@lid')) {
|
||
logger_1.logger.debug({
|
||
instanceId,
|
||
remoteJid: msg.key.remoteJid,
|
||
senderPn: msg.key.senderPn,
|
||
pushName: msg.pushName,
|
||
}, '[LID] Mensagem @lid recebida — será resolvida pelo MessageHandler');
|
||
}
|
||
}
|
||
// A persistência do mapeamento LID→phone vive dentro do
|
||
// MessageHandler.processMessage (no upsert do Contact), porque
|
||
// updateMany aqui afetava 0 rows em contatos inexistentes — bug
|
||
// silencioso que descartava o mapeamento na primeira mensagem LID.
|
||
// Delega processamento completo para o MessageHandler
|
||
msgHandler.handle(m);
|
||
});
|
||
// ─── Status de mensagens (sent → delivered → read) ───────────────────
|
||
// O WhatsApp envia atualizações de status conforme a mensagem é entregue
|
||
// e lida. Mapeamos os códigos numéricos para strings legíveis.
|
||
sock.ev.on('messages.update', (updates) => {
|
||
this.touchActivity(instanceId);
|
||
for (const { key, update } of updates) {
|
||
if (!key.id || !update.status)
|
||
continue;
|
||
const statusMap = {
|
||
0: 'PENDING', 1: 'PENDING', 2: 'SENT', 3: 'DELIVERED', 4: 'READ', 5: 'READ',
|
||
};
|
||
const status = statusMap[update.status];
|
||
if (!status)
|
||
continue;
|
||
// Atualiza no banco e emite para o frontend (ticks azuis/cinza)
|
||
prisma_1.prisma.message.updateMany({
|
||
where: { instanceId, messageId: key.id },
|
||
data: { status: status },
|
||
}).then((r) => {
|
||
if (r.count === 0)
|
||
return;
|
||
this.io.to(`tenant:${tenantId}`).emit('message:status', {
|
||
messageId: key.id,
|
||
status,
|
||
});
|
||
hook_bus_1.hookBus.emit('ext:message.update', {
|
||
instanceId,
|
||
tenantId,
|
||
messageId: key.id,
|
||
status,
|
||
timestamp: Date.now(),
|
||
}).catch(() => { });
|
||
}).catch(() => { });
|
||
}
|
||
});
|
||
// ─── Contatos ────────────────────────────────────────────────────────
|
||
// contacts.upsert: lista completa de contatos (conexão inicial/sync)
|
||
// contacts.update: atualizações parciais (nome alterado, etc)
|
||
const contactHandler = new ContactHandler_1.ContactHandler(instanceId, tenantId, sock, this.io);
|
||
this.contactHandlers.set(instanceId, contactHandler);
|
||
sock.ev.on('contacts.upsert', (contacts) => {
|
||
this.touchActivity(instanceId);
|
||
this.baileysLog(tenantId, instanceId, 'contacts.upsert', { count: contacts.length });
|
||
logger_1.logger.info({ instanceId, count: contacts.length }, '[contacts.upsert] Recebido');
|
||
contactHandler.upsert(contacts);
|
||
});
|
||
sock.ev.on('contacts.update', (contacts) => {
|
||
this.touchActivity(instanceId);
|
||
this.baileysLog(tenantId, instanceId, 'contacts.update', { count: contacts.length });
|
||
contactHandler.update(contacts);
|
||
});
|
||
// ─── LID → Phone mapping (chats.phoneNumberShare) ────────────────────
|
||
// Evento emitido quando um contato envia uma mensagem de protocolo
|
||
// SHARE_PHONE_NUMBER, revelando o telefone real por trás do LID.
|
||
//
|
||
// Este é um dos mecanismos para construir o mapeamento LID → phone.
|
||
// Outros mecanismos:
|
||
// - key.senderPn nas mensagens (MessageHandler.resolveLidToPhoneJid)
|
||
// - campo `lid` nos contatos do history sync (ContactHandler.upsert)
|
||
//
|
||
// @see MessageHandler.resolveLidToPhoneJid() — consome este mapeamento
|
||
sock.ev.on('chats.phoneNumberShare', ({ lid, jid: phoneJid }) => {
|
||
this.touchActivity(instanceId);
|
||
logger_1.logger.info({ instanceId, lid, phoneJid }, '[LID] phoneNumberShare recebido');
|
||
if (lid && phoneJid) {
|
||
// Salva o mapeamento LID no contato que já existe com o phone JID
|
||
prisma_1.prisma.contact.updateMany({
|
||
where: { tenantId, instanceId, jid: phoneJid },
|
||
data: { lidJid: lid },
|
||
}).catch(() => { });
|
||
// Tenta corrigir contatos que foram criados com JID = LID
|
||
// (pode acontecer se o contato chegou antes do phoneNumberShare)
|
||
prisma_1.prisma.contact.updateMany({
|
||
where: { tenantId, instanceId, jid: lid },
|
||
data: { jid: phoneJid, phone: phoneJid.split('@')[0], lidJid: lid },
|
||
}).catch(() => { });
|
||
}
|
||
});
|
||
// ─── Chats ───────────────────────────────────────────────────────────
|
||
// chats.upsert: novos chats (ou chats que voltaram da lista)
|
||
// chats.update: atualizações (unread count, pin, archive, etc)
|
||
sock.ev.on('chats.upsert', (chats) => {
|
||
this.touchActivity(instanceId);
|
||
this.baileysLog(tenantId, instanceId, 'chats.upsert', { count: chats.length });
|
||
this.syncChats(instanceId, tenantId, chats).catch(() => { });
|
||
});
|
||
sock.ev.on('chats.update', (chats) => {
|
||
this.touchActivity(instanceId);
|
||
this.baileysLog(tenantId, instanceId, 'chats.update', { count: chats.length });
|
||
this.handleChatsUpdate(instanceId, tenantId, chats).catch(() => { });
|
||
});
|
||
// ─── Grupos ──────────────────────────────────────────────────────────
|
||
// groups.upsert: grupo criado ou entrou em novo grupo
|
||
// groups.update: subject (nome) ou metadata alterados
|
||
// Convertemos para o formato de chat e sincronizamos via syncChats()
|
||
sock.ev.on('groups.upsert', (groups) => {
|
||
this.touchActivity(instanceId);
|
||
this.baileysLog(tenantId, instanceId, 'groups.upsert', { count: groups.length });
|
||
const chatsFromGroups = groups.map((g) => ({
|
||
id: g.id,
|
||
name: g.subject,
|
||
conversationTimestamp: g.creation,
|
||
unreadCount: 0,
|
||
pinned: false,
|
||
archived: false,
|
||
}));
|
||
this.syncChats(instanceId, tenantId, chatsFromGroups).catch(() => { });
|
||
});
|
||
sock.ev.on('groups.update', (updates) => {
|
||
this.touchActivity(instanceId);
|
||
this.baileysLog(tenantId, instanceId, 'groups.update', { count: updates.length });
|
||
const chatsFromUpdates = updates
|
||
.filter((g) => g.id && g.subject)
|
||
.map((g) => ({ id: g.id, name: g.subject }));
|
||
this.syncChats(instanceId, tenantId, chatsFromUpdates).catch(() => { });
|
||
});
|
||
// ─── Histórico inicial (messaging-history.set) ───────────────────────
|
||
// Evento principal de sincronização: o telefone envia chats, contatos
|
||
// e mensagens em lotes. Pode disparar múltiplas vezes.
|
||
// Quando isLatest=true, o sync terminou → dispara reconciliação de nomes.
|
||
sock.ev.on('messaging-history.set', async ({ chats: histChats, contacts: histContacts, messages: histMessages, isLatest }) => {
|
||
this.touchActivity(instanceId);
|
||
logger_1.logger.info({
|
||
instanceId,
|
||
chats: histChats.length,
|
||
contacts: histContacts.length,
|
||
messages: histMessages.length,
|
||
isLatest,
|
||
}, '[History] messaging-history.set recebido');
|
||
this.baileysLog(tenantId, instanceId, 'messaging-history.set', {
|
||
chats: histChats.length,
|
||
contacts: histContacts.length,
|
||
messages: histMessages.length,
|
||
isLatest,
|
||
});
|
||
// Processa na ordem: contatos → chats → mensagens
|
||
// (contatos primeiro para que o vínculo Chat→Contact funcione)
|
||
if (histContacts.length > 0) {
|
||
await contactHandler.upsert(histContacts).catch(() => { });
|
||
}
|
||
if (histChats.length > 0) {
|
||
await this.syncChats(instanceId, tenantId, histChats).catch(() => { });
|
||
}
|
||
if (histMessages.length > 0) {
|
||
await msgHandler.handleHistory(histMessages).catch(() => { });
|
||
}
|
||
// Quando isLatest=true, o sync do histórico terminou completamente.
|
||
// Inicia reconciliação: busca contatos sem nome e preenche via pushName
|
||
// das mensagens recebidas (o history sync não traz pushName nos contatos).
|
||
if (isLatest) {
|
||
logger_1.logger.info({ instanceId }, '[History] Sync completo (isLatest=true) — iniciando reconciliação de nomes');
|
||
contactHandler.reconcileNamesFromMessages().catch(() => { });
|
||
}
|
||
});
|
||
// ── Guard: emite DISCONNECTED se o Baileys lançar erro síncrono ─────────
|
||
// makeWASocket pode lançar em casos raros (OpenSSL, auth corrompida, etc.)
|
||
// Não há como capturar erros async do Baileys aqui além dos event listeners,
|
||
// mas garantimos que o socket foi registrado antes de prosseguir.
|
||
if (!this.sockets.has(instanceId)) {
|
||
logger_1.logger.error({ instanceId }, 'Socket Baileys não foi registrado após inicialização — marcando como DISCONNECTED');
|
||
await this.updateInstanceStatus(instanceId, client_1.InstanceStatus.DISCONNECTED);
|
||
this.io.to(`tenant:${tenantId}`).emit('instance:status', { instanceId, status: client_1.InstanceStatus.DISCONNECTED });
|
||
}
|
||
}
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// HANDLER DE ATUALIZAÇÃO DE CONEXÃO
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
/**
|
||
* Processa eventos de mudança de estado da conexão Baileys.
|
||
*
|
||
* Estados possíveis:
|
||
* - qr: QR Code gerado → emite para o frontend escanear
|
||
* - open: conectado com sucesso → inicia sync, watchdog, etc.
|
||
* - close: desconectado → decide se reconecta ou para
|
||
* - connecting: tentando conectar → atualiza status
|
||
*
|
||
* DECISÃO DE RECONEXÃO:
|
||
* - loggedOut (401) → sessão expirada, limpa arquivos, exige novo QR
|
||
* - forbidden (403) → conta banida, para completamente
|
||
* - Qualquer outro código → reconecta com backoff exponencial
|
||
*/
|
||
async handleConnectionUpdate(instanceId, tenantId, update, sock) {
|
||
const { connection, lastDisconnect, qr } = update;
|
||
// ── QR Code emitido ───────────────────────────────────────────────────
|
||
if (qr) {
|
||
logger_1.logger.info({ instanceId }, 'QR Code gerado — aguardando escaneamento');
|
||
const qrBase64 = await qrcode_1.default.toDataURL(qr);
|
||
// Salva no cache com TTL de 60s (QR expira rapidamente)
|
||
await dragonfly_1.dragonfly.set(CACHE_KEY_QR(instanceId), qrBase64, 60);
|
||
await this.updateInstanceStatus(instanceId, client_1.InstanceStatus.QR_PENDING);
|
||
// Emite para a sala da instância (página de conexão) E para o tenant (dashboard)
|
||
this.io.to(`instance:${instanceId}`).emit('qr', { instanceId, qrBase64 });
|
||
this.io.to(`tenant:${tenantId}`).emit('instance:status', {
|
||
instanceId,
|
||
status: client_1.InstanceStatus.QR_PENDING,
|
||
qrBase64,
|
||
});
|
||
// ext: bridge — satélites externos recebem via WS /api/ext/v1/stream
|
||
hook_bus_1.hookBus.emit('ext:session.qr', { instanceId, tenantId, qrBase64, timestamp: Date.now() }).catch(() => { });
|
||
}
|
||
// ── Conectado com sucesso ─────────────────────────────────────────────
|
||
if (connection === 'open') {
|
||
logger_1.logger.info({ instanceId }, 'WhatsApp conectado com sucesso');
|
||
// Reseta contadores de reconexão
|
||
this.reconnectAttempts.set(instanceId, 0);
|
||
this.clearReconnectTimer(instanceId);
|
||
// Inicia watchdog para detectar conexão morta
|
||
this.startWatchdog(instanceId);
|
||
await dragonfly_1.dragonfly.del(CACHE_KEY_QR(instanceId));
|
||
await this.updateInstanceStatus(instanceId, client_1.InstanceStatus.CONNECTED);
|
||
// Salva o número de telefone da sessão no banco
|
||
// (sock.user.id vem no formato "5511999999999:1@s.whatsapp.net")
|
||
const phoneJid = sock.user?.id;
|
||
if (phoneJid) {
|
||
const phone = phoneJid.split(':')[0].split('@')[0];
|
||
await prisma_1.prisma.instance.update({ where: { id: instanceId }, data: { phone } })
|
||
.catch(() => { });
|
||
}
|
||
// Pós-conexão: sincroniza avatar da instância e busca avatares ausentes
|
||
const handler = this.contactHandlers.get(instanceId);
|
||
if (handler) {
|
||
// Avatar da própria instância (foto de perfil do número)
|
||
handler.syncInstanceAvatar(instanceId)
|
||
.then((avatar) => {
|
||
if (avatar) {
|
||
this.io.to(`tenant:${tenantId}`).emit('instance:avatar', { instanceId, avatar });
|
||
}
|
||
})
|
||
.catch(() => { });
|
||
// Busca avatares ausentes após 5s (dá tempo para o sync inicial)
|
||
setTimeout(() => handler.syncMissingAvatars().catch(() => { }), 5000);
|
||
// Renova avatares próximos de expirar após 30s (não compete com syncMissing)
|
||
setTimeout(() => handler.syncExpiredAvatars().catch(() => { }), 30000);
|
||
}
|
||
// Sync incremental: verifica gap de mensagens durante downtime
|
||
this.syncMissedMessages(instanceId, tenantId).catch(() => { });
|
||
this.io.to(`tenant:${tenantId}`).emit('instance:status', {
|
||
instanceId,
|
||
status: client_1.InstanceStatus.CONNECTED,
|
||
});
|
||
hook_bus_1.hookBus.emit('ext:session.status', { instanceId, tenantId, status: 'connected', timestamp: Date.now() }).catch(() => { });
|
||
}
|
||
// ── Desconectado ──────────────────────────────────────────────────────
|
||
if (connection === 'close') {
|
||
const rawErr = lastDisconnect?.error;
|
||
const error = rawErr?.output;
|
||
const statusCode = error?.statusCode;
|
||
const isNetworkErr = this.isTransientNetworkError(rawErr);
|
||
logger_1.logger.warn({ instanceId, statusCode, isNetworkErr }, 'Conexao encerrada');
|
||
// Limpa referências da instância desconectada
|
||
this.sockets.delete(instanceId);
|
||
this.contactHandlers.delete(instanceId);
|
||
this.msgHandlers.delete(instanceId);
|
||
this.stopWatchdog(instanceId);
|
||
// Decide se deve reconectar baseado no código de erro
|
||
const shouldReconnect = statusCode !== baileys_1.DisconnectReason.loggedOut &&
|
||
statusCode !== baileys_1.DisconnectReason.forbidden;
|
||
if (shouldReconnect) {
|
||
const attempts = (this.reconnectAttempts.get(instanceId) ?? 0) + 1;
|
||
this.reconnectAttempts.set(instanceId, attempts);
|
||
// Backoff exponencial: 3s, 6s, 12s, 24s, 48s…
|
||
// Teto menor (60s) para erros de rede transientes: DNS/ECONN se
|
||
// resolvem em segundos, não faz sentido dormir 5min. Teto geral
|
||
// (5min) fica para erros de servidor/protocolo do WhatsApp, onde
|
||
// paciência é melhor do que pressa.
|
||
const maxDelay = isNetworkErr ? RECONNECT_NETWORK_MAX_MS : RECONNECT_MAX_DELAY_MS;
|
||
const delay = Math.min(RECONNECT_BASE_DELAY_MS * Math.pow(2, attempts - 1), maxDelay);
|
||
const scheduledAt = Date.now() + delay;
|
||
const nextAt = new Date(scheduledAt).toISOString();
|
||
logger_1.logger.info({ instanceId, attempts, delayMs: delay, nextAt, isNetworkErr }, '[Reconexao] Agendando reconexao com backoff exponencial');
|
||
await this.updateInstanceStatus(instanceId, client_1.InstanceStatus.CONNECTING);
|
||
this.io.to(`tenant:${tenantId}`).emit('instance:status', {
|
||
instanceId,
|
||
status: client_1.InstanceStatus.CONNECTING,
|
||
reconnectIn: delay,
|
||
attempt: attempts,
|
||
});
|
||
this.scheduleReconnect(instanceId, tenantId, delay);
|
||
}
|
||
else {
|
||
// loggedOut → limpa sessão e exige novo QR
|
||
if (statusCode === baileys_1.DisconnectReason.loggedOut) {
|
||
logger_1.logger.warn({ instanceId }, 'Sessao desconectada pelo usuario — limpando sessao');
|
||
await this.deleteSessionFiles(instanceId);
|
||
}
|
||
// forbidden → conta banida, apenas atualiza status
|
||
await this.updateInstanceStatus(instanceId, client_1.InstanceStatus.DISCONNECTED);
|
||
this.io.to(`tenant:${tenantId}`).emit('instance:status', {
|
||
instanceId,
|
||
status: client_1.InstanceStatus.DISCONNECTED,
|
||
reason: baileys_1.DisconnectReason[statusCode] ?? statusCode,
|
||
});
|
||
hook_bus_1.hookBus.emit('ext:session.status', { instanceId, tenantId, status: 'disconnected', timestamp: Date.now() }).catch(() => { });
|
||
}
|
||
}
|
||
// ── Conectando ────────────────────────────────────────────────────────
|
||
if (connection === 'connecting') {
|
||
await this.updateInstanceStatus(instanceId, client_1.InstanceStatus.CONNECTING);
|
||
this.io.to(`tenant:${tenantId}`).emit('instance:status', {
|
||
instanceId,
|
||
status: client_1.InstanceStatus.CONNECTING,
|
||
});
|
||
}
|
||
}
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// SYNC INCREMENTAL — Mensagens perdidas durante downtime
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
/**
|
||
* Verifica e notifica sobre gap de mensagens durante downtime.
|
||
*
|
||
* Consulta a última mensagem no banco e calcula quanto tempo o servidor
|
||
* ficou offline. O Baileys com syncFullHistory=true já solicita as
|
||
* mensagens pendentes automaticamente — este método apenas:
|
||
* 1. Loga o gap para visibilidade (monitoramento)
|
||
* 2. Emite 'instance:syncing' para o frontend mostrar indicador de sync
|
||
*/
|
||
async syncMissedMessages(instanceId, tenantId) {
|
||
try {
|
||
const lastMsg = await prisma_1.prisma.message.findFirst({
|
||
where: { instanceId },
|
||
orderBy: { timestamp: 'desc' },
|
||
select: { timestamp: true, messageId: true },
|
||
});
|
||
if (!lastMsg) {
|
||
logger_1.logger.info({ instanceId }, '[Sync] Nenhuma mensagem no DB — history sync completo via Baileys');
|
||
return;
|
||
}
|
||
const gap = Date.now() - lastMsg.timestamp.getTime();
|
||
const gapMinutes = Math.round(gap / 60000);
|
||
logger_1.logger.info({ instanceId, lastMsgAt: lastMsg.timestamp, gapMinutes }, '[Sync] Reconectado — gap de mensagens detectado');
|
||
this.baileysLog(tenantId, instanceId, 'sync:gap', {
|
||
lastMsgAt: lastMsg.timestamp.toISOString(),
|
||
gapMinutes,
|
||
});
|
||
// Notifica o frontend que está sincronizando
|
||
this.io.to(`tenant:${tenantId}`).emit('instance:syncing', {
|
||
instanceId,
|
||
gapMinutes,
|
||
since: lastMsg.timestamp.toISOString(),
|
||
});
|
||
}
|
||
catch (err) {
|
||
logger_1.logger.error({ err, instanceId }, '[Sync] Erro ao verificar gap de mensagens');
|
||
}
|
||
}
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// WATCHDOG — Detecta conexão morta silenciosamente
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
/**
|
||
* Registra timestamp da última atividade para o watchdog.
|
||
* Chamado por TODOS os event listeners do Baileys.
|
||
*/
|
||
touchActivity(instanceId) {
|
||
this.lastActivity.set(instanceId, Date.now());
|
||
}
|
||
/**
|
||
* Inicia o watchdog para uma instância.
|
||
*
|
||
* Fluxo conservador em 3 etapas — escolhido pra minimizar reconexões:
|
||
* 1. Se inativo > TIMEOUT (10 min): envia um presence update como
|
||
* ping leve e marca timestamp. Se o socket estiver vivo, Baileys
|
||
* responde e touchActivity é chamado no próximo evento.
|
||
* 2. Se continua inativo GRACE (+30s) após o ping: confirma que a
|
||
* conexão está de fato morta. Procede pro kill.
|
||
* 3. Antes de matar: verifica cool-down (15 min desde último kill)
|
||
* pra não entrar em loop quando a rede está oscilando. Se estiver
|
||
* no cool-down, só loga e espera o próximo ciclo.
|
||
*
|
||
* Esta sequência dá ~10-11 min de folga antes do primeiro kill, e no
|
||
* mínimo 15 min entre kills consecutivos — 7-8× mais conservador que
|
||
* a versão anterior (2 min sem verificação de estado).
|
||
*/
|
||
startWatchdog(instanceId) {
|
||
this.stopWatchdog(instanceId);
|
||
const timer = setInterval(async () => {
|
||
const now = Date.now();
|
||
const lastTs = this.lastActivity.get(instanceId) ?? 0;
|
||
const elapsed = now - lastTs;
|
||
const tenantId = this.tenantByInstance.get(instanceId);
|
||
if (elapsed <= WATCHDOG_TIMEOUT_MS) {
|
||
// Conexão com atividade recente — reseta estado de ping/cool-down
|
||
this.lastWatchdogPing.delete(instanceId);
|
||
return;
|
||
}
|
||
const sock = this.sockets.get(instanceId);
|
||
// Caso 1: socket sumiu sem connection.close — reconecta imediatamente
|
||
if (!sock) {
|
||
if (tenantId) {
|
||
logger_1.logger.warn({ instanceId }, '[Watchdog] Socket ausente — reagendando conexao');
|
||
this.stopWatchdog(instanceId);
|
||
this.connect(instanceId, tenantId).catch(() => { });
|
||
}
|
||
return;
|
||
}
|
||
const pingedAt = this.lastWatchdogPing.get(instanceId) ?? 0;
|
||
const sincePing = now - pingedAt;
|
||
// Etapa 1: envia ping leve (presence update) se ainda não pingou
|
||
// neste ciclo de inatividade. Não conta como atividade — só estímulo.
|
||
if (pingedAt === 0 || sincePing > WATCHDOG_TIMEOUT_MS) {
|
||
try {
|
||
await sock.sendPresenceUpdate('available');
|
||
this.lastWatchdogPing.set(instanceId, now);
|
||
logger_1.logger.debug({ instanceId, elapsedMs: elapsed }, '[Watchdog] Ping leve enviado — aguardando resposta');
|
||
}
|
||
catch (err) {
|
||
// Ping falhou → socket já está morto, pula pra etapa de kill
|
||
logger_1.logger.warn({ instanceId, err }, '[Watchdog] Ping falhou — socket morto');
|
||
this.forceKillSocket(instanceId, sock, tenantId, elapsed);
|
||
}
|
||
return;
|
||
}
|
||
// Etapa 2: ping já foi enviado, mas ainda sem atividade após GRACE.
|
||
// Confirma que o socket está zumbi e força reconexão.
|
||
if (sincePing > WATCHDOG_PING_GRACE_MS) {
|
||
this.forceKillSocket(instanceId, sock, tenantId, elapsed);
|
||
}
|
||
}, WATCHDOG_INTERVAL_MS);
|
||
this.watchdogTimers.set(instanceId, timer);
|
||
}
|
||
/**
|
||
* Força o fechamento do socket, respeitando cool-down para evitar loop.
|
||
* Só mata se não houve outro kill nos últimos WATCHDOG_COOLDOWN_MS.
|
||
*/
|
||
forceKillSocket(instanceId, sock, tenantId, elapsedMs) {
|
||
const now = Date.now();
|
||
const lastKill = this.lastWatchdogKill.get(instanceId) ?? 0;
|
||
const sinceKill = now - lastKill;
|
||
if (sinceKill < WATCHDOG_COOLDOWN_MS) {
|
||
logger_1.logger.warn({ instanceId, elapsedMs, sinceLastKillMs: sinceKill }, '[Watchdog] Em cool-down — adiando kill para proteger numero de ban');
|
||
return;
|
||
}
|
||
logger_1.logger.warn({ instanceId, elapsedMs }, '[Watchdog] Conexao morta confirmada — forcando reconexao');
|
||
this.lastWatchdogKill.set(instanceId, now);
|
||
this.lastWatchdogPing.delete(instanceId);
|
||
try {
|
||
sock.end(new Error('Watchdog timeout'));
|
||
}
|
||
catch { /* ignore */ }
|
||
// Se o sock.end não disparar connection.close (raro), reconecta manualmente
|
||
if (!this.sockets.has(instanceId) && tenantId) {
|
||
this.stopWatchdog(instanceId);
|
||
this.connect(instanceId, tenantId).catch(() => { });
|
||
}
|
||
}
|
||
/** Para o watchdog de uma instância */
|
||
stopWatchdog(instanceId) {
|
||
const timer = this.watchdogTimers.get(instanceId);
|
||
if (timer) {
|
||
clearInterval(timer);
|
||
this.watchdogTimers.delete(instanceId);
|
||
}
|
||
// Limpa estado de ping pendente — novo ciclo começa limpo.
|
||
// NÃO limpa lastWatchdogKill aqui: o cool-down deve sobreviver a
|
||
// stop/start para bloquear reconexões em cascata.
|
||
this.lastWatchdogPing.delete(instanceId);
|
||
}
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// TIMER DE RECONEXÃO
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
/** Cancela timer de reconexão pendente (evita reconexão duplicada) */
|
||
clearReconnectTimer(instanceId) {
|
||
const timer = this.reconnectTimers.get(instanceId);
|
||
if (timer) {
|
||
clearTimeout(timer);
|
||
this.reconnectTimers.delete(instanceId);
|
||
}
|
||
this.reconnectScheduledAt.delete(instanceId);
|
||
}
|
||
/**
|
||
* Agenda uma reconexão após `delay` ms.
|
||
*
|
||
* Centraliza o setTimeout + tracking do scheduledAt (usado pelo network
|
||
* probe pra saber se vale adiantar). Cancela qualquer timer anterior.
|
||
*/
|
||
scheduleReconnect(instanceId, tenantId, delay) {
|
||
this.clearReconnectTimer(instanceId);
|
||
const scheduledAt = Date.now() + delay;
|
||
this.reconnectScheduledAt.set(instanceId, scheduledAt);
|
||
const timer = setTimeout(() => {
|
||
this.reconnectScheduledAt.delete(instanceId);
|
||
this.connect(instanceId, tenantId);
|
||
}, delay);
|
||
this.reconnectTimers.set(instanceId, timer);
|
||
}
|
||
/**
|
||
* Classifica um erro de conexão como "rede transiente".
|
||
*
|
||
* Erros de DNS/TCP costumam resolver em segundos. Erros de status do
|
||
* WhatsApp (loggedOut, conflict, restartRequired) são categoria diferente
|
||
* e devem usar o backoff longo tradicional.
|
||
*/
|
||
isTransientNetworkError(err) {
|
||
if (!err)
|
||
return false;
|
||
const code = err.code || err.cause?.code || err.errno;
|
||
if (code && typeof code === 'string') {
|
||
const transient = [
|
||
'EAI_AGAIN', // DNS retry
|
||
'ENOTFOUND', // DNS não resolveu
|
||
'ECONNRESET',
|
||
'ECONNREFUSED',
|
||
'ETIMEDOUT',
|
||
'EHOSTUNREACH',
|
||
'ENETUNREACH',
|
||
'ENETDOWN',
|
||
'EPIPE',
|
||
];
|
||
if (transient.includes(code))
|
||
return true;
|
||
}
|
||
// Fallback: mensagem contém nome do host — é quase certo que é DNS/WS
|
||
const msg = (err.message || '').toString();
|
||
if (/getaddrinfo|web\.whatsapp\.com|WebSocket Error/i.test(msg))
|
||
return true;
|
||
return false;
|
||
}
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// NETWORK PROBE — Acorda reconexões quando a rede volta
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
/**
|
||
* Inicia o network probe global.
|
||
*
|
||
* A cada NETWORK_PROBE_INTERVAL_MS, testa se o DNS resolve
|
||
* `web.whatsapp.com`. Se resolver E houver alguma instância com retry
|
||
* agendado para daqui a mais de PROBE_ADVANCE_THRESHOLD_MS, cancela o
|
||
* timer dessa instância e dispara o retry imediatamente.
|
||
*
|
||
* Por quê DNS em vez de HTTP? Zero bytes para o WhatsApp. DNS falha com
|
||
* EAI_AGAIN quando a rede está caída, e resolve rápido quando volta —
|
||
* é um sinal confiável de disponibilidade de rede sem tocar no WA.
|
||
*/
|
||
startNetworkProbe() {
|
||
if (this.networkProbeTimer)
|
||
return;
|
||
this.networkProbeTimer = setInterval(async () => {
|
||
// Curto-circuito: se não há retries pendentes, nem faz o DNS.
|
||
if (this.reconnectScheduledAt.size === 0)
|
||
return;
|
||
try {
|
||
await promises_2.default.lookup(PROBE_DNS_HOST);
|
||
}
|
||
catch {
|
||
return; // Rede ainda caída — não adianta nada, espera o tick seguinte.
|
||
}
|
||
// DNS OK → adianta qualquer retry que ainda falte > threshold
|
||
const now = Date.now();
|
||
for (const [instanceId, scheduledAt] of this.reconnectScheduledAt) {
|
||
const remaining = scheduledAt - now;
|
||
if (remaining <= PROBE_ADVANCE_THRESHOLD_MS)
|
||
continue;
|
||
const tenantId = this.tenantByInstance.get(instanceId);
|
||
if (!tenantId)
|
||
continue;
|
||
logger_1.logger.info({ instanceId, remainingMs: remaining }, '[NetworkProbe] Rede detectada — adiantando retry pendente');
|
||
this.clearReconnectTimer(instanceId);
|
||
// Dispara sem await — probe roda em interval e não deve bloquear
|
||
this.connect(instanceId, tenantId).catch((err) => logger_1.logger.error({ err, instanceId }, '[NetworkProbe] Falha ao adiantar retry'));
|
||
}
|
||
}, NETWORK_PROBE_INTERVAL_MS);
|
||
// Não segura o processo vivo só por causa do probe
|
||
this.networkProbeTimer.unref?.();
|
||
}
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// DESCONEXÃO MANUAL
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
/**
|
||
* Desconecta uma instância manualmente (logout).
|
||
*
|
||
* Diferente de uma desconexão por erro — aqui o usuário está
|
||
* intencionalmente desconectando. Limpa todos os timers e
|
||
* faz logout no WhatsApp (invalidando a sessão).
|
||
*/
|
||
async disconnect(instanceId) {
|
||
const sock = this.sockets.get(instanceId);
|
||
if (!sock)
|
||
return;
|
||
this.clearReconnectTimer(instanceId);
|
||
this.stopWatchdog(instanceId);
|
||
this.reconnectAttempts.delete(instanceId);
|
||
// Logout manual zera o estado de cool-down: reconexão futura é um
|
||
// novo ciclo e não deve herdar o bloqueio do watchdog.
|
||
this.lastWatchdogKill.delete(instanceId);
|
||
this.lastActivity.delete(instanceId);
|
||
await sock.logout();
|
||
this.sockets.delete(instanceId);
|
||
this.contactHandlers.delete(instanceId);
|
||
this.msgHandlers.delete(instanceId);
|
||
logger_1.logger.info({ instanceId }, 'Instancia desconectada manualmente');
|
||
}
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// ACESSORES PÚBLICOS
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
/** Retorna o socket Baileys ativo para uma instância (usado pelas rotas de envio) */
|
||
getSocket(instanceId) {
|
||
return this.sockets.get(instanceId);
|
||
}
|
||
/** Retorna o Socket.IO server (usado pelas rotas para emitir eventos) */
|
||
getIO() {
|
||
return this.io;
|
||
}
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// HELPERS INTERNOS
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
/**
|
||
* Atualiza o status da instância no banco E no cache.
|
||
* Dupla escrita para que o frontend possa consultar status tanto
|
||
* via API (banco) quanto via polling rápido (cache DragonflyDB).
|
||
*/
|
||
async updateInstanceStatus(instanceId, status) {
|
||
await Promise.all([
|
||
prisma_1.prisma.instance.update({ where: { id: instanceId }, data: { status } })
|
||
.catch((err) => { if (err?.code !== 'P2025')
|
||
throw err; }),
|
||
dragonfly_1.dragonfly.set(CACHE_KEY_STATUS(instanceId), status),
|
||
]);
|
||
}
|
||
/** Remove arquivos de sessão do disco (após loggedOut) */
|
||
async deleteSessionFiles(instanceId) {
|
||
const sessionDir = path_1.default.resolve(env_1.env.BAILEYS_SESSIONS_PATH, instanceId);
|
||
await promises_1.default.rm(sessionDir, { recursive: true, force: true });
|
||
}
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// SINCRONIZAÇÃO DE CHATS
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
/**
|
||
* Sincroniza lista de chats no banco.
|
||
*
|
||
* Chamado por: chats.upsert, chats.update, groups.upsert, groups.update,
|
||
* messaging-history.set
|
||
*
|
||
* Para cada chat:
|
||
* - Cria ou atualiza no banco com JID, nome (grupo), timestamps
|
||
* - Vincula ao Contact existente (se houver)
|
||
* - Sincroniza estado de pin/archive/unread do WhatsApp
|
||
*
|
||
* Otimização: busca todos os contatos existentes em uma única query
|
||
* e usa Map para lookup O(1) por JID.
|
||
*/
|
||
async syncChats(instanceId, tenantId, chats) {
|
||
if (chats.length === 0)
|
||
return;
|
||
// Busca todos os contatos existentes para os JIDs (1 query)
|
||
const jids = chats.map((c) => c.id).filter(Boolean);
|
||
const existingContacts = await prisma_1.prisma.contact.findMany({
|
||
where: { tenantId, instanceId, jid: { in: jids } },
|
||
select: { id: true, jid: true },
|
||
});
|
||
const contactIdByJid = new Map(existingContacts.map((c) => [c.jid, c.id]));
|
||
// Pré-carrega mapeamento LID → phone JID para chats @lid.
|
||
// ContactHandler.upsert já rodou antes (messaging-history.set processa
|
||
// contacts → chats → messages nessa ordem), então lidJid já está populado.
|
||
const lidJids = jids.filter((j) => j.endsWith('@lid'));
|
||
const lidToPhoneJid = new Map(); // @lid → @s.whatsapp.net
|
||
const lidToContactId = new Map(); // @lid → contactId
|
||
if (lidJids.length > 0) {
|
||
const resolvedByLid = await prisma_1.prisma.contact.findMany({
|
||
where: {
|
||
tenantId,
|
||
instanceId,
|
||
lidJid: { in: lidJids },
|
||
NOT: { jid: { endsWith: '@lid' } }, // só aceita se resolveu para phone JID
|
||
},
|
||
select: { id: true, jid: true, lidJid: true },
|
||
});
|
||
for (const rc of resolvedByLid) {
|
||
if (rc.lidJid) {
|
||
lidToPhoneJid.set(rc.lidJid, rc.jid);
|
||
lidToContactId.set(rc.lidJid, rc.id);
|
||
}
|
||
}
|
||
}
|
||
for (const chat of chats) {
|
||
const rawJid = chat.id;
|
||
if (!rawJid)
|
||
continue;
|
||
// Descarta JIDs de sistema: broadcasts, newsletters e conta PSA (0@s.whatsapp.net)
|
||
if (rawJid.includes('@broadcast') || rawJid.endsWith('@newsletter') || rawJid.startsWith('0@'))
|
||
continue;
|
||
// ── Resolução LID → phone JID ─────────────────────────────────────
|
||
// Chats @lid são artefatos do protocolo WhatsApp multi-device:
|
||
// o JID real do contato é @s.whatsapp.net.
|
||
// Se já temos o mapeamento (ContactHandler rodou antes), usa o phone JID.
|
||
// Se não temos resolução, descarta — seria filtrado pelo ChatRepository.
|
||
let jid = rawJid;
|
||
if (rawJid.endsWith('@lid')) {
|
||
const phoneJid = lidToPhoneJid.get(rawJid);
|
||
if (phoneJid) {
|
||
jid = phoneJid;
|
||
// Garante que o contactId mapeado seja usado abaixo
|
||
const resolvedContactId = lidToContactId.get(rawJid) ?? contactIdByJid.get(jid);
|
||
if (resolvedContactId)
|
||
contactIdByJid.set(jid, resolvedContactId);
|
||
}
|
||
else {
|
||
// Sem resolução disponível: chat será criado via handleHistory quando
|
||
// a mensagem chegar com senderPn resolvível.
|
||
logger_1.logger.debug({ rawJid, instanceId }, '[syncChats] @lid sem resolução — descartando');
|
||
continue;
|
||
}
|
||
}
|
||
try {
|
||
// Converte timestamp Unix (segundos) para Date
|
||
const lastAt = chat.conversationTimestamp
|
||
? new Date(Number(chat.conversationTimestamp) * 1000)
|
||
: undefined;
|
||
const isGroup = jid.endsWith('@g.us');
|
||
// Nome do chat: apenas grupos têm nome (subject) — individuais usam Contact.name
|
||
const groupName = isGroup ? (chat.name ?? null) : null;
|
||
const contactId = contactIdByJid.get(jid) ?? null;
|
||
await prisma_1.prisma.chat.upsert({
|
||
where: { tenantId_instanceId_jid: { tenantId, instanceId, jid } },
|
||
create: {
|
||
tenantId,
|
||
instanceId,
|
||
jid,
|
||
name: groupName,
|
||
unreadCount: chat.unreadCount ?? 0,
|
||
lastMessageAt: lastAt ?? null,
|
||
isPinned: !!chat.pinned,
|
||
isArchived: chat.archived ?? false,
|
||
...(contactId && { contactId }),
|
||
},
|
||
update: {
|
||
...(groupName && { name: groupName }),
|
||
...(contactId && { contactId }),
|
||
// Só aplica campos que vieram explicitamente no evento.
|
||
// chats.upsert pode ser parcial (nova mensagem, metadata) — se
|
||
// pinned/archived/unreadCount estiverem ausentes, não sobrescreve
|
||
// o valor salvo no banco.
|
||
...(chat.unreadCount !== undefined && { unreadCount: chat.unreadCount }),
|
||
...(lastAt && { lastMessageAt: lastAt }),
|
||
...(chat.pinned !== undefined && { isPinned: !!chat.pinned }),
|
||
...(chat.archived !== undefined && { isArchived: !!chat.archived }),
|
||
},
|
||
});
|
||
}
|
||
catch (err) {
|
||
logger_1.logger.error({ err, jid }, '[WhatsApp] Erro ao sincronizar chat');
|
||
}
|
||
}
|
||
}
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// HANDLER DE ATUALIZAÇÕES PARCIAIS DE CHAT (chats.update)
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
/**
|
||
* Processa atualizações parciais de chats (pin, archive, unread).
|
||
*
|
||
* Diferente de syncChats (usado para bulk/history), este método:
|
||
* 1. Só atualiza campos presentes na atualização (sem sobrescrever com defaults)
|
||
* 2. Emite chat:upsert via Socket.IO para atualizar o frontend em tempo real
|
||
*
|
||
* O evento chats.update do Baileys traz objetos parciais — apenas os campos
|
||
* que mudaram. Por isso não podemos usar ?? 0 para unreadCount, pois isso
|
||
* sobrescreveria o valor correto com zero.
|
||
*/
|
||
async handleChatsUpdate(instanceId, tenantId, updates) {
|
||
for (const update of updates) {
|
||
const jid = update.id;
|
||
if (!jid || jid.endsWith('@lid'))
|
||
continue;
|
||
try {
|
||
// Monta patch com apenas os campos presentes na atualização
|
||
const patch = {};
|
||
if (update.pinned !== undefined)
|
||
patch.isPinned = !!update.pinned;
|
||
if (update.archived !== undefined)
|
||
patch.isArchived = !!update.archived;
|
||
if (update.unreadCount !== undefined)
|
||
patch.unreadCount = update.unreadCount;
|
||
if (Object.keys(patch).length === 0)
|
||
continue;
|
||
const chat = await prisma_1.prisma.chat.update({
|
||
where: { tenantId_instanceId_jid: { tenantId, instanceId, jid } },
|
||
data: patch,
|
||
include: {
|
||
contact: {
|
||
select: {
|
||
name: true, verifiedName: true, notify: true,
|
||
phone: true, avatarUrl: true,
|
||
scoreReputacao: true, flagRestricao: true,
|
||
},
|
||
},
|
||
},
|
||
}).catch(() => null);
|
||
if (!chat)
|
||
continue;
|
||
// Emite para o frontend atualizar em tempo real
|
||
const c = chat.contact;
|
||
const isGroup = jid.endsWith('@g.us');
|
||
const displayName = isGroup
|
||
? (chat.name ?? null)
|
||
: (c?.name ?? c?.verifiedName ?? c?.notify ?? null);
|
||
this.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: c
|
||
? {
|
||
name: displayName,
|
||
phone: c.phone,
|
||
avatarUrl: c.avatarUrl,
|
||
scoreReputacao: c.scoreReputacao,
|
||
flagRestricao: c.flagRestricao,
|
||
}
|
||
: isGroup
|
||
? { name: displayName, phone: null, avatarUrl: null, scoreReputacao: 0, flagRestricao: false }
|
||
: null,
|
||
lastMessage: null,
|
||
});
|
||
}
|
||
catch (err) {
|
||
logger_1.logger.error({ err, jid }, '[WhatsApp] Erro ao aplicar chats.update');
|
||
}
|
||
}
|
||
}
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// DEBUG LOG (página /baileys no frontend)
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
/**
|
||
* Emite evento de debug para a página /baileys do frontend.
|
||
*
|
||
* Permite monitorar em tempo real todos os eventos Baileys
|
||
* sem precisar acessar os logs do servidor.
|
||
*/
|
||
baileysLog(tenantId, instanceId, event, data) {
|
||
this.io.to(`tenant:${tenantId}`).emit('baileys:log', {
|
||
timestamp: new Date().toISOString(),
|
||
sessionId: instanceId,
|
||
event,
|
||
data,
|
||
});
|
||
}
|
||
}
|
||
exports.WhatsAppConnectionManager = WhatsAppConnectionManager;
|
||
//# sourceMappingURL=WhatsAppConnectionManager.js.map
|