970 lines
49 KiB
JavaScript
970 lines
49 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 {
|
|
sock;
|
|
instanceId;
|
|
tenantId;
|
|
io;
|
|
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).
|
|
*/
|
|
peerRecipientHints = new Map();
|
|
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;
|
|
}
|
|
/**
|
|
* 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), 60_000).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),
|
|
// Armazena payload da mídia para permitir re-download posterior
|
|
...(this.isMediaMessage(contentType) ? {
|
|
mediaPayload: this.buildMediaPayload(key, message),
|
|
} : {}),
|
|
},
|
|
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,
|
|
});
|
|
// Persiste sticker na biblioteca (deduplicado por sha256)
|
|
if (contentType === 'stickerMessage') {
|
|
this.persistSticker(msg, mediaUrl).catch(() => { });
|
|
}
|
|
logger_1.logger.debug({ savedMsgId, filePath }, 'Mídia salva');
|
|
}
|
|
catch (err) {
|
|
logger_1.logger.error({ err, savedMsgId }, 'Falha ao baixar mídia');
|
|
}
|
|
}
|
|
async persistSticker(msg, wasabiPath) {
|
|
const stickerMsg = msg.message?.stickerMessage;
|
|
if (!stickerMsg)
|
|
return;
|
|
const sha256Bytes = stickerMsg.fileSha256;
|
|
if (!sha256Bytes || sha256Bytes.length === 0)
|
|
return;
|
|
const sha256 = Buffer.from(sha256Bytes).toString('hex');
|
|
const isAnimated = stickerMsg.isAnimated ?? false;
|
|
await prisma_1.prisma.sticker.upsert({
|
|
where: { tenantId_sha256: { tenantId: this.tenantId, sha256 } },
|
|
create: {
|
|
tenantId: this.tenantId,
|
|
instanceId: this.instanceId,
|
|
sha256,
|
|
wasabiPath,
|
|
isAnimated,
|
|
useCount: 1,
|
|
lastSeenAt: new Date(),
|
|
},
|
|
update: {
|
|
useCount: { increment: 1 },
|
|
lastSeenAt: new Date(),
|
|
instanceId: this.instanceId,
|
|
},
|
|
});
|
|
}
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// PAYLOAD DE MÍDIA (serialização para re-download)
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
/**
|
|
* Constrói o payload serializado da mensagem de mídia para armazenamento.
|
|
* Uint8Array e Buffer são convertidos para { _b64: base64 } para sobreviver ao JSON.
|
|
* jpegThumbnail é descartado para economizar espaço (não necessário para re-download).
|
|
*/
|
|
buildMediaPayload(key, innerMessage) {
|
|
const stripped = this.stripThumbnails(innerMessage);
|
|
return {
|
|
key: { remoteJid: key.remoteJid, fromMe: key.fromMe, id: key.id },
|
|
message: this.serializeForStorage(stripped),
|
|
};
|
|
}
|
|
stripThumbnails(obj) {
|
|
if (!obj || typeof obj !== 'object')
|
|
return obj;
|
|
const result = {};
|
|
for (const [k, v] of Object.entries(obj)) {
|
|
if (k === 'jpegThumbnail' || k === 'thumbnailDirectPath' || k === 'thumbnailSha256' || k === 'thumbnailEncSha256')
|
|
continue;
|
|
result[k] = v;
|
|
}
|
|
return result;
|
|
}
|
|
serializeForStorage(val) {
|
|
if (val instanceof Uint8Array || Buffer.isBuffer(val)) {
|
|
return { _b64: Buffer.from(val).toString('base64') };
|
|
}
|
|
if (Array.isArray(val))
|
|
return val.map(v => this.serializeForStorage(v));
|
|
if (val !== null && typeof val === 'object') {
|
|
// Long de protobuf — converte para número
|
|
if (typeof val.toNumber === 'function')
|
|
return val.toNumber();
|
|
return Object.fromEntries(Object.entries(val).map(([k, v]) => [k, this.serializeForStorage(v)]));
|
|
}
|
|
return val;
|
|
}
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 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;
|