Files
instrucoes_gerais/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/MessageHandler.js
T
VPS 4 Deploy Agent b97f7f94e4 feat: sistema de figurinhas, emoji PT-BR e preloader inteligente
- Stickers: captura automática por fileSha256 (Baileys) no MessageHandler,
  tabela Prisma 'stickers' com deduplicação por [tenantId, sha256],
  API REST /api/stickers (list + favorite toggle), cache IndexedDB permanente
  (sem TTL — conteúdo imutável), StickerPanel.tsx com abas Recentes/Favoritos
- Emoji picker: migrado de emoji-picker-react para @emoji-mart/react com
  locale="pt" (tradução completa: categorias, busca, mensagem de sem resultado)
- Preloader: aparece apenas em hard reload (F5/URL direta) via
  performance.getEntriesByType('navigation').type + sessionStorage flag,
  navegação client-side Next.js pula o overlay instantaneamente
- Avatar cache: Wasabi como storage persistente + IndexedDB client-side,
  avatar_version via MD5(avatarUrl) sem coluna extra no banco,
  rota /api/storage/view/* para servir arquivos Wasabi ao frontend

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:48:36 +02:00

890 lines
46 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.MessageHandler = void 0;
/**
* MessageHandler — Processa mensagens recebidas do Baileys (tempo real e histórico).
*
* Responsabilidades:
* 1. Receber eventos `messages.upsert` do Baileys (tipo 'notify' ou 'append')
* 2. Resolver JIDs LID → telefone (@lid → @s.whatsapp.net)
* 3. Criar/atualizar Contact, Chat e Message no banco
* 4. Emitir eventos Socket.IO para o frontend (message:new, chat:upsert)
* 5. Download assíncrono de mídia (imagens, vídeos, docs)
* 6. Disparar chatbot para mensagens recebidas com texto
*
* CONTEXTO LID (Linked ID):
* A partir de 2024, o WhatsApp passou a usar LID como identificador primário
* nas mensagens peer-to-peer. O remoteJid chega no formato "123456@lid" em vez
* de "5511999999999@s.whatsapp.net". O telefone real está disponível em:
* - key.senderPn: campo do protocolo WA contendo o JID com telefone
* - key.participantPn: idem, usado em contextos de grupo
* - DB lookup: tabela contacts.lidJid (mapeamento persistido anteriormente)
*
* @see WhatsAppConnectionManager — registra os event listeners do Baileys
* @see ContactHandler — gerencia contatos e avatares
*/
const path_1 = __importDefault(require("path"));
const promises_1 = __importDefault(require("fs/promises"));
const engine_1 = require("../engine");
const client_1 = require("@prisma/client");
const prisma_1 = require("../../../infra/database/prisma");
const logger_1 = require("../../../config/logger");
const whatsapp_1 = require("../../../shared/utils/whatsapp");
const StorageProvider_1 = require("../../../core/StorageProvider");
const hook_bus_1 = require("../../../core/hook-bus");
const chat_cache_1 = require("../../chats/chat.cache");
/** Diretório raiz onde os arquivos de mídia são salvos (organizados por instanceId) */
const MEDIA_DIR = path_1.default.resolve('./media');
/**
* Tipos de mensagem do protocolo WhatsApp que NÃO representam conteúdo real para o usuário.
* Mensagens com estes contentTypes são descartadas silenciosamente — sem criar Chat nem Message.
*/
const PROTOCOL_CONTENT_TYPES = new Set([
'senderKeyDistributionMessage', // distribuição de chaves de grupo (protocolo)
'protocolMessage', // revogações, timers de desaparecimento (protocolo)
'ephemeralMessage', // wrapper de mensagem efêmera (o conteúdo real está dentro)
'deviceSentMessage', // eco de mensagem enviada de outro dispositivo (protocolo)
'messageContextInfo', // só metadados, sem conteúdo
'appStateSyncKeyShare', // sync de estado do app (protocolo)
'appStateFatalExceptionNotification', // notificação interna (protocolo)
'keepInChatMessage', // manter no chat (protocolo)
]);
/**
* Wrappers compostos do WhatsApp que aninham a mensagem real em `.message`.
* Sem desembrulhar, getContentType retorna o nome do wrapper (ex:
* `documentWithCaptionMessage` é o que o WhatsApp usa quando o usuário envia
* um PDF com legenda) e mapContentType cai em UNSUPPORTED — a msg é
* descartada silenciosamente. Itera enquanto houver wrapper aninhado.
*/
const COMPOSITE_WRAPPER_TYPES = new Set([
'documentWithCaptionMessage',
'viewOnceMessage',
'viewOnceMessageV2',
'viewOnceMessageV2Extension',
'editedMessage',
'botInvokeMessage',
]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function unwrapInnerMessage(msg) {
let cur = msg;
let depth = 0;
while (cur && depth < 5) {
const keys = Object.keys(cur).filter(k => k !== 'messageContextInfo');
const wrapperKey = keys.find(k => COMPOSITE_WRAPPER_TYPES.has(k));
if (!wrapperKey)
return cur;
const inner = cur[wrapperKey]?.message;
if (!inner || typeof inner !== 'object')
return cur;
cur = inner;
depth++;
}
return cur;
}
class MessageHandler {
constructor(
/** Socket Baileys ativo — usado para download de mídia e reupload */
sock,
/** UUID da instância WhatsApp (1 instância = 1 número conectado) */
instanceId,
/** UUID do tenant (empresa/cliente) — isolamento multi-tenant */
tenantId,
/** Socket.IO server — emite eventos em tempo real para o frontend */
io,
/** Serviço de chatbot (opcional) — processa mensagens recebidas com IA */
chatbotService) {
this.sock = sock;
this.instanceId = instanceId;
this.tenantId = tenantId;
this.io = io;
this.chatbotService = chatbotService;
/**
* Cache de dicas de `peer_recipient_pn` extraídas das stanzas brutas.
*
* Mapa: msgId → phone JID do destinatário (ex: "556799138694@s.whatsapp.net").
*
* Contexto: quando o usuário envia uma mensagem pelo celular (fromMe=true)
* para um contato que usa LID, o WhatsApp sincroniza a mensagem pra nossa
* sessão com remoteJid=@lid. Nesse caso, `key.senderPn` descreve o DONO da
* sessão (não o destinatário) — inútil pra resolução. A ÚNICA pista do
* telefone real é o atributo `peer_recipient_pn` na stanza bruta, que o
* Baileys NÃO expõe em `decode-wa-message.js`.
*
* Por isso, o WhatsAppConnectionManager registra um listener extra em
* `sock.ws.on('CB:message')` que extrai esse atributo e popula este mapa
* ANTES do Baileys emitir `messages.upsert`. Depois, `resolveLidToPhoneJid`
* consome a dica e remove a entrada (mapa efêmero, não cresce).
*/
this.peerRecipientHints = new Map();
}
/**
* Registra uma dica de destinatário extraída da stanza bruta.
*
* Chamado pelo listener `sock.ws.on('CB:message')` no
* WhatsAppConnectionManager. Entradas expiram em 60s como garantia
* contra vazamento caso o `messages.upsert` correspondente nunca chegue.
*/
setPeerRecipientHint(msgId, peerRecipientPn) {
this.peerRecipientHints.set(msgId, peerRecipientPn);
setTimeout(() => this.peerRecipientHints.delete(msgId), 60000).unref();
}
/**
* Ponto de entrada principal — chamado pelo event listener `messages.upsert`.
*
* @param type - 'notify' = mensagem em tempo real; 'append' = histórico sendo sincronizado
*
* Diferença entre os modos:
* - notify: processa completo (salva, emite socket, dispara chatbot, baixa mídia)
* - append: apenas salva no banco em lote (bulk insert), sem emitir eventos
*/
async handle({ messages, type }) {
if (type === 'notify') {
for (const msg of messages) {
try {
await this.processMessage(msg);
}
catch (err) {
logger_1.logger.error({ err, msgId: msg.key.id }, 'Erro ao processar mensagem');
}
}
}
else if (type === 'append') {
await this.handleHistory(messages);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// HISTÓRICO — Salva mensagens em lote durante sync inicial (sem socket/chatbot)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Processa mensagens históricas (history sync / append).
*
* Estratégia: agrupa mensagens por JID e faz 1 upsert de Contact + Chat + createMany
* por grupo, minimizando round-trips ao banco.
*
* Mensagens @lid são resolvidas para o JID real via senderPn ou DB lookup.
* Se não for possível resolver, a mensagem é descartada silenciosamente
* (será capturada quando o mapeamento LID for descoberto via phoneNumberShare).
*/
async handleHistory(messages) {
if (messages.length === 0)
return;
// Define o limite de 2 dias atrás em segundos (timestamp do WhatsApp usa segundos)
const doisDiasAtrasEmSegundos = Math.floor((Date.now() - 2 * 24 * 60 * 60 * 1000) / 1000);
const mensagensRecentes = [];
const mensagensAntigas = [];
// Separa as mensagens por idade
for (const msg of messages) {
const ts = Number(msg.messageTimestamp ?? 0);
if (ts >= doisDiasAtrasEmSegundos) {
mensagensRecentes.push(msg);
}
else {
mensagensAntigas.push(msg);
}
}
// 1. PROCESSAMENTO DE ALTA PRIORIDADE (Imediato)
if (mensagensRecentes.length > 0) {
logger_1.logger.info({
instanceId: this.instanceId,
count: mensagensRecentes.length
}, '[History] Sincronizando conversas recentes (últimos 2 dias) com prioridade máxima!');
await this.processHistoryBatch(mensagensRecentes);
}
// 2. PROCESSAMENTO DE BAIXA PRIORIDADE (Gradual em segundo plano)
if (mensagensAntigas.length > 0) {
logger_1.logger.info({
instanceId: this.instanceId,
count: mensagensAntigas.length
}, '[History] Agendando histórico antigo para sincronização gradual em background...');
this.processOlderHistoryGradually(mensagensAntigas);
}
}
/**
* Executa o processamento gradual do histórico antigo em lotes de 100 msgs a cada 3 segundos
*/
processOlderHistoryGradually(messages) {
const tamanhoDoLote = 100;
const intervaloMs = 3000; // 3 segundos de folga para a CPU/Postgres respirar
let offset = 0;
const processarProximoLote = async () => {
// Se processou tudo, encerra o ciclo
if (offset >= messages.length) {
logger_1.logger.info({ instanceId: this.instanceId }, '[History] Sincronização completa de histórico antigo concluída com sucesso!');
return;
}
// Corta o próximo pedaço do histórico
const lote = messages.slice(offset, offset + tamanhoDoLote);
offset += tamanhoDoLote;
try {
await this.processHistoryBatch(lote);
}
catch (err) {
logger_1.logger.error({ err, instanceId: this.instanceId }, '[History] Erro ao processar lote gradual de histórico antigo');
}
// Agenda o próximo lote de forma não bloqueante
setTimeout(processarProximoLote, intervaloMs).unref();
};
// Inicia o sincronismo em background após 5 segundos, garantindo que
// a conexão inicial e o frontend já estejam 100% carregados e estáveis
setTimeout(processarProximoLote, 5000).unref();
}
/**
* Lógica original de agrupamento e escrita no banco (Refatorada em método auxiliar)
*/
async processHistoryBatch(messages) {
if (messages.length === 0)
return;
// JID próprio da sessão (ex: "5511999@s.whatsapp.net") — usado para filtrar self-chat
const ownJid = (0, whatsapp_1.normalizeJid)(this.sock.user?.id ?? '');
// Agrupa por JID para minimizar queries (1 chat upsert + 1 createMany por JID)
const byJid = new Map();
for (const msg of messages) {
let jidRaw = msg.key.remoteJid;
if (!jidRaw || jidRaw.includes('@broadcast') || jidRaw.endsWith('@newsletter') || jidRaw.startsWith('0@') || !msg.message)
continue;
// Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais)
const normalizedRaw = (0, whatsapp_1.normalizeJid)(jidRaw);
if (ownJid && normalizedRaw === ownJid)
continue;
// ── Resolução LID → phone JID para mensagens históricas ──────────
// O history sync do WhatsApp pode enviar mensagens com remoteJid @lid.
// Precisamos converter para @s.whatsapp.net antes de persistir,
// caso contrário o chat ficaria duplicado (um com LID, outro com phone).
//
// Fontes de resolução (ordem de prioridade):
// 1. key.remoteJidAlt — InfiniteAPI v7+ inclui o JID alternativo (phone ↔ LID)
// 2. key.senderPn / participantPn — campo do protocolo WA
// 3. DB lookup — contacts.lidJid salvo por sessões anteriores
if (jidRaw.endsWith('@lid')) {
// 1. remoteJidAlt: InfiniteAPI preenche o JID alternativo em mensagens históricas.
// Quando remoteJid=@lid, remoteJidAlt contém @s.whatsapp.net (e vice-versa).
const remoteJidAlt = msg.key.remoteJidAlt;
if (remoteJidAlt && remoteJidAlt.endsWith('@s.whatsapp.net')) {
jidRaw = (0, whatsapp_1.normalizeJid)(remoteJidAlt);
}
else {
// 2. senderPn / participantPn
const senderPn = msg.key.senderPn || msg.key.participantPn;
if (senderPn) {
jidRaw = (0, whatsapp_1.normalizeJid)(senderPn);
if (!jidRaw.endsWith('@s.whatsapp.net'))
continue;
}
else {
// 3. DB lookup — mapeamento LID → phone salvo pelo ContactHandler
const contact = await prisma_1.prisma.contact.findFirst({
where: { tenantId: this.tenantId, instanceId: this.instanceId, lidJid: jidRaw },
select: { jid: true },
});
if (contact?.jid && !contact.jid.endsWith('@lid')) {
jidRaw = contact.jid;
}
else {
// Sem resolução possível — descarta (será capturada via phoneNumberShare futuro)
continue;
}
}
}
}
const jid = (0, whatsapp_1.normalizeJid)(jidRaw);
const list = byJid.get(jid) ?? [];
list.push(msg);
byJid.set(jid, list);
}
let totalSaved = 0;
for (const [jid, msgs] of byJid) {
try {
// Calcula o timestamp mais recente para atualizar lastMessageAt do chat
const latestTs = msgs.reduce((max, m) => Math.max(max, Number(m.messageTimestamp ?? 0)), 0);
const lastAt = latestTs ? new Date(latestTs * 1000) : null;
// 1. Garantir que o Contato existe (cria sem nome — será preenchido pela reconciliação)
const contact = await prisma_1.prisma.contact.upsert({
where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid } },
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid,
phone: jid.endsWith('@g.us') ? null : jid.split('@')[0],
},
update: {},
});
// 2. Garantir que o Chat existe e está vinculado ao Contact
const chat = await prisma_1.prisma.chat.upsert({
where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid } },
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid,
contactId: contact.id,
lastMessageAt: lastAt,
unreadCount: 0
},
update: {
contactId: contact.id,
...(lastAt && { lastMessageAt: lastAt })
},
});
// 3. Prepara registros para bulk insert (createMany com skipDuplicates)
const rows = msgs.flatMap((msg) => {
const { key, messageTimestamp } = msg;
let message = msg.message;
if (!message || !key.id)
return [];
message = unwrapInnerMessage(message);
const contentType = (0, engine_1.getContentType)(message);
if (!contentType)
return [];
// Descarta tipos de protocolo/sistema que não representam conteúdo real
if (PROTOCOL_CONTENT_TYPES.has(contentType))
return [];
const msgType = this.mapContentType(contentType);
if (msgType === client_1.MessageType.UNSUPPORTED)
return [];
const ts = messageTimestamp ? new Date(Number(messageTimestamp) * 1000) : new Date();
// Extrai o corpo textual — tenta vários campos em ordem de prioridade
const body = message.conversation ??
message.extendedTextMessage?.text ??
message.imageMessage?.caption ??
message.videoMessage?.caption ??
message.documentMessage?.caption ??
null;
const isGroup = jid.endsWith('@g.us');
return [{
tenantId: this.tenantId,
instanceId: this.instanceId,
chatId: chat.id,
remoteJid: jid,
messageId: key.id,
fromMe: key.fromMe ?? false,
type: msgType,
body,
// pushName: nome público do remetente (só confiável em msgs recebidas)
pushName: (key.fromMe ? null : msg.pushName) ?? null,
// senderJid: em grupos, identifica quem enviou (JID do participante)
senderJid: isGroup ? (key.participant ?? null) : null,
status: 'READ', // históricas são consideradas já lidas
timestamp: ts,
}];
});
if (rows.length > 0) {
const { count } = await prisma_1.prisma.message.createMany({ data: rows, skipDuplicates: true });
totalSaved += count;
}
}
catch (err) {
logger_1.logger.error({ err, jid }, '[History] Erro ao salvar mensagens do chat');
}
}
if (totalSaved > 0) {
logger_1.logger.info({ totalSaved, chats: byJid.size, instanceId: this.instanceId }, `[History] Lote de ${totalSaved} mensagens históricas salvas`);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// TEMPO REAL — Processa uma única mensagem (notify)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Processa uma mensagem recebida em tempo real.
*
* Fluxo completo:
* 1. Filtra broadcasts, newsletters
* 2. Resolve LID → telefone (se @lid)
* 3. Cria/atualiza Contact com pushName (se aplicável)
* 4. Cria/atualiza Chat vinculado ao Contact
* 5. Persiste a Message no banco
* 6. Emite socket events (message:new, chat:upsert)
* 7. Agenda download de mídia (assíncrono)
* 8. Dispara chatbot (assíncrono)
*/
async processMessage(msg) {
const { key, messageTimestamp, pushName } = msg;
let message = msg.message;
if (!message || !key.remoteJid)
return;
// Desembrulha wrappers compostos (documentWithCaptionMessage, viewOnceMessage, etc).
// Sem isso, getContentType retornaria o nome do wrapper e cairia em UNSUPPORTED.
message = unwrapInnerMessage(message);
// Ignora mensagens de status broadcast, newsletters e conta PSA do WhatsApp
if (key.remoteJid === 'status@broadcast')
return;
if (key.remoteJid.endsWith('@newsletter'))
return;
if (key.remoteJid.startsWith('0@'))
return;
// ── Resolução LID → telefone ──────────────────────────────────────────
// WhatsApp moderno usa LID (Linked ID) como remoteJid em vez de @s.whatsapp.net.
// O telefone real está em key.senderPn (quando disponível) ou no mapeamento DB.
//
// SEM esta resolução, mensagens de contatos que usam LID seriam descartadas
// e o chat apareceria com a foto/número errado no frontend (bug crítico).
//
// Prioridade de resolução:
// 1. key.senderPn — campo do protocolo WA (mais confiável, disponível ~95% dos casos)
// 2. key.participantPn — idem, usado em contextos de grupo
// 3. DB lookup — contacts.lidJid (mapeamento salvo por processamento anterior ou phoneNumberShare)
/**
* Guarda o JID @lid original quando a resolução é bem-sucedida, para
* persistir em `contacts.lidJid` mais abaixo (após o upsert do Contact).
* Tentar salvar ANTES do upsert via `updateMany` afeta 0 rows para
* contatos novos — bug silencioso que fazia o mapeamento ser perdido.
*/
let originalLidJid = null;
if (key.remoteJid.endsWith('@lid')) {
const phoneJid = await this.resolveLidToPhoneJid(key.remoteJid, key);
if (!phoneJid) {
// Sem resolução: não há como saber o número real do contato.
// A mensagem será perdida, mas o mapeamento será capturado no futuro
// via chats.phoneNumberShare e as próximas mensagens serão processadas.
logger_1.logger.warn({ remoteJid: key.remoteJid, instanceId: this.instanceId, fromMe: key.fromMe }, '[MessageHandler] @lid sem resolução de telefone — descartando');
return;
}
originalLidJid = key.remoteJid;
key.remoteJid = phoneJid;
}
const contentType = (0, engine_1.getContentType)(message);
if (!contentType)
return;
// Descarta tipos de protocolo/sistema (sem conteúdo real para o usuário)
if (PROTOCOL_CONTENT_TYPES.has(contentType))
return;
const msgType = this.mapContentType(contentType);
// Descarta tipos completamente desconhecidos — evita poluir o DB com UNSUPPORTED
if (msgType === client_1.MessageType.UNSUPPORTED)
return;
const fromMe = key.fromMe ?? false;
const remoteJidRaw = key.remoteJid;
/** JID normalizado (sem sufixo de dispositivo :1, :2 etc) */
const remoteJid = (0, whatsapp_1.normalizeJid)(remoteJidRaw);
const messageId = key.id;
// Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais)
const ownJid = (0, whatsapp_1.normalizeJid)(this.sock.user?.id ?? '');
if (ownJid && remoteJid === ownJid)
return;
// ── 1. Contact: criar ou atualizar ────────────────────────────────────
// REGRA IMPORTANTE sobre pushName:
// - pushName é o "nome público" que o remetente escolheu no WhatsApp
// - Para mensagens RECEBIDAS (fromMe=false) de contatos individuais: é confiável
// - Para mensagens ENVIADAS (fromMe=true): pushName é o nome da NOSSA sessão
// → NUNCA sobrescrever o nome do contato destinatário com isso ("efeito espelho")
// - Para GRUPOS: pushName é do remetente individual, não do grupo
// → O nome do grupo vem de groups.upsert → Chat.name (subject)
const isGroupMessage = remoteJid.endsWith('@g.us');
const participantJid = key.participant ?? msg.participant ?? null;
const safePushName = (!fromMe && !isGroupMessage && pushName) ? pushName : null;
const contact = await prisma_1.prisma.contact.upsert({
where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid: remoteJid } },
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: remoteJid,
name: safePushName,
notify: safePushName,
phone: isGroupMessage ? null : remoteJid.split('@')[0],
// Se resolvemos a partir de @lid, persiste o mapeamento já na criação.
// Sem isso, o próximo @lid do mesmo contato falharia o DB lookup.
...(originalLidJid && { lidJid: originalLidJid }),
},
update: {
// Atualiza apenas pushname (notify) — preserva name se já veio de contacts.upsert
...(safePushName && { notify: safePushName }),
// Garante que o lidJid fique salvo mesmo em contatos pré-existentes
// que ainda não tinham o mapeamento (ex: criados via history sync).
...(originalLidJid && { lidJid: originalLidJid }),
},
});
// ── 2. Chat: criar ou atualizar, vinculando ao Contact ────────────────
const chat = await prisma_1.prisma.chat.upsert({
where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid: remoteJid } },
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: remoteJid,
contactId: contact.id,
unreadCount: fromMe ? 0 : 1,
lastMessageAt: new Date(messageTimestamp * 1000),
},
update: {
contactId: contact.id, // Garante o vínculo (corrige chats órfãos antigos)
lastMessageAt: new Date(messageTimestamp * 1000),
// fromMe: zera unread (estamos respondendo); !fromMe: incrementa
unreadCount: fromMe ? { set: 0 } : { increment: 1 },
},
});
// ── 3. Extrai corpo textual da mensagem ───────────────────────────────
// nativeFlowResponseMessage: resposta ao clique de botão/lista
let nativeFlowBody = null;
if (message.interactiveResponseMessage?.nativeFlowResponseMessage) {
try {
const params = JSON.parse(message.interactiveResponseMessage.nativeFlowResponseMessage.paramsJson ?? '{}');
nativeFlowBody = params.display_text ?? params.title ?? params.id ?? null;
}
catch { /* ignora JSON inválido */ }
}
const body = message.conversation ??
message.extendedTextMessage?.text ??
message.imageMessage?.caption ??
message.videoMessage?.caption ??
message.documentMessage?.caption ??
nativeFlowBody ??
null;
// ── 4. Contexto de reply (se for resposta a outra mensagem) ───────────
const replyToWaId = message.extendedTextMessage?.contextInfo?.stanzaId ?? null;
let replyToId = null;
if (replyToWaId) {
const replyMsg = await prisma_1.prisma.message.findUnique({
where: { instanceId_messageId: { instanceId: this.instanceId, messageId: replyToWaId } },
});
replyToId = replyMsg?.id ?? null;
}
// ── 5. Persiste a mensagem (mídia será baixada depois) ────────────────
const saved = await prisma_1.prisma.message.upsert({
where: { instanceId_messageId: { instanceId: this.instanceId, messageId } },
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
chatId: chat.id,
remoteJid,
messageId,
fromMe,
type: msgType,
body,
pushName: fromMe ? null : (pushName ?? null),
senderJid: isGroupMessage ? participantJid : null,
replyToId,
status: fromMe ? 'SENT' : 'PENDING',
timestamp: new Date(messageTimestamp * 1000),
},
update: {}, // Se já existe (duplicata), não sobrescreve
});
// ── 6. Emite eventos Socket.IO ────────────────────────────────────────
// 6a. Para quem está com o chat aberto (sala chat:{id})
// Inclui replyTo quando é uma resposta — o frontend precisa para exibir
// a caixa de citação (quoted block) em tempo real, sem precisar recarregar.
const emitMsg = replyToId
? await prisma_1.prisma.message.findUnique({
where: { id: saved.id },
include: {
replyTo: {
select: { id: true, messageId: true, body: true, fromMe: true, type: true, mediaUrl: true },
},
},
}) ?? saved
: saved;
this.io.to(`chat:${chat.id}`).emit('message:new', {
...emitMsg,
pushName,
senderJid: isGroupMessage ? participantJid : null,
});
// ext: bridge — satélites externos recebem via WS /api/ext/v1/stream
hook_bus_1.hookBus.emit('ext:message.new', {
instanceId: this.instanceId,
tenantId: this.tenantId,
chatId: chat.id,
message: { ...emitMsg, pushName, senderJid: isGroupMessage ? participantJid : null },
timestamp: Date.now(),
}).catch(() => { });
// 6b. Para TODOS os clientes do tenant (atualiza a lista de chats)
// Necessário para: novos chats, chats não abertos, badge de unread
this.emitChatUpsert(chat.id, { body, fromMe, status: saved.status, type: msgType, timestamp: saved.timestamp });
// ── 7. Download de mídia assíncrono (não bloqueia o event loop) ────────
if (this.isMediaMessage(contentType)) {
// Passa msg com message desembrulhada (necessário p/ documentWithCaptionMessage etc).
const msgUnwrapped = { ...msg, message };
setImmediate(() => this.downloadAndPersistMedia(msgUnwrapped, saved.id, chat.id, contentType));
}
// ── 8. Dispara chatbot para mensagens recebidas com texto ─────────────
if (!fromMe && body && this.chatbotService) {
setImmediate(() => this.chatbotService.handleIncoming({
tenantId: this.tenantId,
instanceId: this.instanceId,
chatId: chat.id,
jid: remoteJid,
text: body,
sock: this.sock,
}).catch((err) => logger_1.logger.error({ err }, '[Chatbot] Erro assíncrono')));
}
}
// ═══════════════════════════════════════════════════════════════════════════
// RESOLUÇÃO LID → TELEFONE
// ═══════════════════════════════════════════════════════════════════════════
/**
* Converte um JID LID (ex: "123456789@lid") para o JID com telefone
* (ex: "5511999999999@s.whatsapp.net").
*
* O WhatsApp moderno usa LID (Linked ID) como identificador interno.
* Para exibir o contato corretamente, precisamos mapear para o telefone real.
*
* Fontes de resolução (em ordem de prioridade):
* 1. key.senderPn / key.participantPn — campo do protocolo WA.
* Útil para mensagens RECEBIDAS e contextos de grupo.
* 2. peerRecipientHints[msgId] — dica extraída da stanza bruta
* (atributo `peer_recipient_pn`, não exposto pelo Baileys).
* Única fonte confiável para mensagens fromMe=true enviadas pelo
* celular pra um contato @lid que ainda não temos mapeado.
* 3. DB lookup — contacts.lidJid (mapeamento persistido anteriormente).
* Populado por: processamento anterior, chats.phoneNumberShare,
* ou contacts.upsert do history sync.
*
* A persistência do mapeamento LID→phone foi movida para processMessage
* (após o upsert do Contact), para evitar o bug anterior onde o
* `updateMany` disparado aqui afetava 0 rows em contatos inexistentes.
*
* @param lidJid - O JID no formato @lid (ex: "123456789@lid")
* @param key - WAMessageKey (id, fromMe, senderPn, participantPn)
* @returns O JID @s.whatsapp.net correspondente, ou null se não resolver
*/
async resolveLidToPhoneJid(lidJid, key) {
// 1. senderPn vem direto do protocolo WhatsApp (mais confiável)
// OBS: em mensagens fromMe=true sincronizadas do celular, senderPn
// descreve o DONO da sessão, não o destinatário — inútil aqui.
const senderPn = key.senderPn || key.participantPn;
if (senderPn && !key.fromMe) {
const phoneJid = (0, whatsapp_1.normalizeJid)(senderPn);
if (phoneJid && phoneJid.endsWith('@s.whatsapp.net')) {
return phoneJid;
}
}
// 2. Dica do peer_recipient_pn (stanza bruta) — fonte primária para
// mensagens fromMe=true que chegam via sync de outro dispositivo.
if (key.fromMe && key.id) {
const hint = this.peerRecipientHints.get(key.id);
this.peerRecipientHints.delete(key.id); // uso único
if (hint) {
const phoneJid = (0, whatsapp_1.normalizeJid)(hint);
if (phoneJid && phoneJid.endsWith('@s.whatsapp.net')) {
return phoneJid;
}
}
}
// 3. Busca no DB por mapeamento LID salvo anteriormente
const contact = await prisma_1.prisma.contact.findFirst({
where: { tenantId: this.tenantId, instanceId: this.instanceId, lidJid },
select: { jid: true },
});
if (contact?.jid)
return contact.jid;
return null;
}
// ═══════════════════════════════════════════════════════════════════════════
// EMISSÃO DE EVENTOS SOCKET.IO
// ═══════════════════════════════════════════════════════════════════════════
/**
* Emite evento `chat:upsert` para todos os clientes do tenant.
*
* Usado para atualizar a lista de chats no frontend em tempo real:
* - Novos chats aparecem instantaneamente
* - Chats existentes atualizam lastMessage, unreadCount, etc.
* - Chats @lid são filtrados (não devem aparecer no frontend)
*
* O frontend usa este evento para reordenar a lista por lastMessageAt
* e exibir o preview da última mensagem.
*/
async emitChatUpsert(chatId, lastMessage) {
try {
const chat = await prisma_1.prisma.chat.findUnique({
where: { id: chatId },
include: {
contact: {
select: {
name: true,
verifiedName: true,
notify: true,
phone: true,
avatarUrl: true,
scoreReputacao: true,
flagRestricao: true,
},
},
},
});
if (!chat)
return;
// Chats @lid são artefatos internos — nunca expor ao frontend
if (chat.jid.endsWith('@lid'))
return;
const c = chat.contact;
const isGroup = chat.jid.endsWith('@g.us');
// Regra de resolução de nome:
// Grupos: usa Chat.name (subject do grupo, vem de groups.upsert)
// Individuais: name (agenda) > verifiedName (business) > notify (pushname)
// NUNCA usar pushName do remetente como nome de grupo
const displayName = isGroup
? (chat.name ?? null)
: (c?.name ?? c?.verifiedName ?? c?.notify ?? null);
this.io.to(`tenant:${this.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,
});
// Invalida cache da listagem — força refetch na próxima request da inbox
(0, chat_cache_1.invalidateChatListCache)(this.tenantId, this.instanceId).catch(() => { });
}
catch (err) {
logger_1.logger.error({ err, chatId }, '[MessageHandler] Falha ao emitir chat:upsert');
}
}
// ═══════════════════════════════════════════════════════════════════════════
// DOWNLOAD DE MÍDIA
// ═══════════════════════════════════════════════════════════════════════════
/**
* Baixa o conteúdo de mídia de uma mensagem e salva no disco.
*
* Executado via setImmediate() para não bloquear o event loop.
* Após salvar, atualiza o registro da mensagem com mediaPath/mediaUrl
* e emite `message:media_ready` para o frontend renderizar o preview.
*
* Organização: /media/{instanceId}/{messageId}.{ext}
*/
async downloadAndPersistMedia(msg, savedMsgId, chatId, contentType) {
try {
const buffer = await (0, engine_1.downloadMediaMessage)(msg, 'buffer', {}, { logger: logger_1.logger, reuploadRequest: this.sock.updateMediaMessage });
if (!buffer)
return;
// Para documentos: preserva nome e extensão originais do arquivo
const docMsg = msg.message?.documentMessage;
const originalFileName = docMsg?.fileName?.trim() || null;
const originalMimetype = docMsg?.mimetype || this.mimetypeForType(contentType);
let ext;
if (contentType === 'documentMessage' && originalFileName) {
ext = path_1.default.extname(originalFileName).replace('.', '') || 'bin';
}
else {
ext = this.extensionForType(contentType);
}
const fileName = `${savedMsgId}.${ext}`;
const filePath = path_1.default.join(MEDIA_DIR, this.instanceId, fileName);
await promises_1.default.mkdir(path_1.default.dirname(filePath), { recursive: true });
await promises_1.default.writeFile(filePath, buffer);
// Tenta upload para Wasabi; se falhar usa path local como fallback
let mediaUrl = `/media/${this.instanceId}/${fileName}`;
let mediaPath = filePath;
let uploadedToRemote = false;
if (StorageProvider_1.storageProvider.isRegistered()) {
try {
const result = await StorageProvider_1.storageProvider.uploadFile({
category: 'whatsapp',
usuarioSis: this.tenantId,
sessionJID: this.instanceId,
file: {
originalname: originalFileName ?? fileName,
buffer: buffer,
mimetype: originalMimetype,
},
});
mediaUrl = result.path;
if (result.provider !== 'local_fallback') {
mediaPath = null;
uploadedToRemote = true;
}
logger_1.logger.debug({ savedMsgId, path: result.path, provider: result.provider }, 'Mídia enviada ao storage');
}
catch (uploadErr) {
logger_1.logger.warn({ uploadErr, savedMsgId }, 'Upload Wasabi falhou, usando path local');
}
}
await prisma_1.prisma.message.update({
where: { id: savedMsgId },
data: {
mediaPath,
mediaUrl,
// Preserva o nome original do documento para exibição no frontend
...(contentType === 'documentMessage' && originalFileName
? { fileName: originalFileName }
: {}),
},
});
if (uploadedToRemote) {
promises_1.default.unlink(filePath).catch((err) => {
logger_1.logger.error({ err, filePath }, 'Erro ao deletar arquivo local após upload remoto');
});
}
// Notifica frontend que a mídia está pronta para exibição
this.io.to(`chat:${chatId}`).emit('message:media_ready', {
messageId: savedMsgId,
mediaUrl,
});
logger_1.logger.debug({ savedMsgId, filePath }, 'Mídia salva');
}
catch (err) {
logger_1.logger.error({ err, savedMsgId }, 'Falha ao baixar mídia');
}
}
// ═══════════════════════════════════════════════════════════════════════════
// UTILITÁRIOS
// ═══════════════════════════════════════════════════════════════════════════
/** Verifica se o contentType é de mídia (precisa de download) */
isMediaMessage(contentType) {
return ['imageMessage', 'videoMessage', 'audioMessage', 'documentMessage', 'stickerMessage'].includes(contentType);
}
/**
* Mapeia o contentType do Baileys para o enum MessageType do Prisma.
* Tipos não reconhecidos são mapeados para UNSUPPORTED.
*/
mapContentType(contentType) {
const map = {
conversation: client_1.MessageType.TEXT,
extendedTextMessage: client_1.MessageType.TEXT,
imageMessage: client_1.MessageType.IMAGE,
videoMessage: client_1.MessageType.VIDEO,
audioMessage: client_1.MessageType.AUDIO,
documentMessage: client_1.MessageType.DOCUMENT,
stickerMessage: client_1.MessageType.STICKER,
locationMessage: client_1.MessageType.LOCATION,
contactMessage: client_1.MessageType.CONTACT,
reactionMessage: client_1.MessageType.REACTION,
pollCreationMessage: client_1.MessageType.POLL,
pollCreationMessageV2: client_1.MessageType.POLL, // poll multi-select grupos
pollCreationMessageV3: client_1.MessageType.POLL, // poll single-select
// Resposta ao clique de botão / seleção de item de lista
interactiveResponseMessage: client_1.MessageType.INTERACTIVE,
// Mensagem interativa recebida de outro remetente (botões/lista/carrossel)
interactiveMessage: client_1.MessageType.BUTTONS,
};
return map[contentType] ?? client_1.MessageType.UNSUPPORTED;
}
/** Retorna a extensão de arquivo adequada para o tipo de mídia */
extensionForType(contentType) {
const map = {
imageMessage: 'jpg',
videoMessage: 'mp4',
audioMessage: 'ogg',
documentMessage: 'bin',
stickerMessage: 'webp',
};
return map[contentType] ?? 'bin';
}
/** Retorna o mimetype adequado para o tipo de mídia */
mimetypeForType(contentType) {
const map = {
imageMessage: 'image/jpeg',
videoMessage: 'video/mp4',
audioMessage: 'audio/ogg',
documentMessage: 'application/octet-stream',
stickerMessage: 'image/webp',
};
return map[contentType] ?? 'application/octet-stream';
}
}
exports.MessageHandler = MessageHandler;
//# sourceMappingURL=MessageHandler.js.map