Files
instrucoes_gerais/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/ContactHandler.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

505 lines
26 KiB
JavaScript

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ContactHandler = void 0;
const prisma_1 = require("../../../infra/database/prisma");
const logger_1 = require("../../../config/logger");
// ─── Configuração de rate-limit para busca de avatares ──────────────────────
// O WhatsApp bloqueia (ban temporário) se muitas requisições profilePictureUrl
// forem feitas em curto período. Estas constantes controlam o throttling.
/** Quantos avatares buscar em paralelo por lote */
const AVATAR_BATCH_SIZE = 5;
/** Delay entre lotes de avatares (~1 req/s por JID → abaixo do threshold do WA) */
const AVATAR_DELAY_MS = 900;
/** Máximo de avatares a buscar no sync pós-conexão (evita overload em contas grandes) */
const MAX_MISSING_AVATAR_SYNC = 80;
class ContactHandler {
constructor(
/** UUID da instância WhatsApp */
instanceId,
/** UUID do tenant (isolamento multi-tenant) */
tenantId,
/** Socket Baileys — usado para profilePictureUrl e resyncAppState */
sock,
/** Socket.IO — emite eventos de avatar/reconciliação para o frontend */
io) {
this.instanceId = instanceId;
this.tenantId = tenantId;
this.sock = sock;
this.io = io;
/** Fila de JIDs aguardando busca de avatar */
this.avatarQueue = [];
/** Flag de mutex simples — evita processamento concorrente da fila */
this.processingAvatars = false;
}
// ═══════════════════════════════════════════════════════════════════════════
// RESOLUÇÃO DE NOME
// ═══════════════════════════════════════════════════════════════════════════
/**
* Regra de ouro para resolução de nome de contato:
* name (agenda do telefone) > verifiedName (conta business) > notify (pushname)
*
* Retorna undefined se nenhum campo estiver disponível.
*/
resolveName(c) {
return c.name ?? c.verifiedName ?? c.notify ?? undefined;
}
// ═══════════════════════════════════════════════════════════════════════════
// UPSERT EM MASSA (contacts.upsert + messaging-history.set)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Cria ou atualiza contatos em massa.
*
* Dispara em dois cenários:
* 1. Evento contacts.upsert do Baileys (conexão inicial ou reconexão)
* 2. Campo contacts[] do messaging-history.set (history sync)
*
* Para cada contato:
* - Cria ou atualiza no banco com nome resolvido
* - Vincula chats órfãos (Chat sem contactId) ao contato
* - Enfileira busca de avatar se imgUrl não disponível
* - Captura mapeamento LID → phone JID (campo `lid` do history sync)
*
* TRATAMENTO DE LID:
* O Baileys pode enviar contatos com c.id no formato @lid.
* Quando o contato do history sync inclui campo `jid` (telefone real),
* usamos esse JID como chave primária e salvamos o LID em lidJid.
* Quando não há campo `jid`, o contato é criado com o LID como JID
* (será corrigido quando phoneNumberShare ou senderPn revelar o telefone).
*/
async upsert(contacts) {
/** JIDs que precisam buscar avatar via profilePictureUrl */
const needFetchAvatar = [];
/** Contador de avatares que já vieram prontos do Baileys (imgUrl direto) */
let directAvatarCount = 0;
for (const c of contacts) {
if (!c.id)
continue;
const isLid = c.id.endsWith('@lid');
const isBroadcast = c.id.includes('@broadcast');
if (isBroadcast)
continue;
// imgUrl pode vir do Baileys com valores especiais:
// - URL string: avatar disponível (salva direto)
// - 'changed': avatar foi alterado (precisa buscar novamente)
// - null/undefined: não disponível (precisa buscar via API)
const imgUrl = c.imgUrl;
// LID mapping: o history sync do Baileys traz campo `lid` nos contatos
// que associa o LID ao contato com JID real (@s.whatsapp.net)
// Ex: { id: "5511999999999@s.whatsapp.net", lid: "123456789@lid" }
const lidFromContact = c.lid;
try {
// Para contatos @lid que possuem o JID real do telefone:
// - Baileys oficial envia o campo `jid` (@s.whatsapp.net)
// - InfiniteAPI v7+ envia o campo `phoneNumber` (@s.whatsapp.net)
// Ambos são suportados — o primeiro disponível vence.
// Isso evita criar contatos duplicados (um com LID, outro com phone).
const pnJid = c.jid
?? c.phoneNumber;
const contactJid = isLid && pnJid ? pnJid : c.id;
// Quando o contato É um @lid mas foi resolvido para phoneJid:
// lidJid = c.id (o @lid original) para manter o mapeamento no banco
// Quando o contato TEM campo `lid` (history sync Baileys oficial):
// lidJid = lidFromContact
const finalLidJid = isLid && pnJid ? c.id : (lidFromContact ?? undefined);
const contact = await prisma_1.prisma.contact.upsert({
where: {
tenantId_instanceId_jid: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: contactJid,
},
},
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: contactJid,
name: this.resolveName(c) ?? null,
verifiedName: c.verifiedName ?? null,
notify: c.notify ?? null,
phone: contactJid.endsWith('@lid') ? null : contactJid.split('@')[0],
...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}),
...(finalLidJid ? { lidJid: finalLidJid } : {}),
},
update: {
name: this.resolveName(c) ?? null,
verifiedName: c.verifiedName ?? null,
notify: c.notify ?? null,
...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}),
...(finalLidJid ? { lidJid: finalLidJid } : {}),
},
});
// Garante vínculo Chat ↔ Contact para chats órfãos
// (chats criados antes do contato existir ficam com contactId=null)
// Usa contactJid (JID resolvido) porque chats @lid são salvos com phone JID.
// Quando c.id é @lid mas foi resolvido para pnJid, o chat no banco tem pnJid.
await prisma_1.prisma.chat.updateMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: contactJid,
contactId: null,
},
data: { contactId: contact.id },
});
// ── Lógica de avatar ──────────────────────────────────────────────
// JIDs @lid não suportam profilePictureUrl. Usa contactJid para verificar
// pois @lid resolvido para phone JID deve buscar avatar normalmente.
const resolvedIsLid = contactJid.endsWith('@lid');
if (!resolvedIsLid) {
if (imgUrl && imgUrl !== 'changed') {
// Avatar veio pronto do Baileys — notifica frontend imediatamente
directAvatarCount++;
this.io?.to(`tenant:${this.tenantId}`).emit('contact:avatar', {
instanceId: this.instanceId,
jid: contactJid,
avatarUrl: imgUrl,
});
}
else if (imgUrl === 'changed' || imgUrl === undefined) {
// Precisa buscar via API — adiciona à fila com rate-limit
needFetchAvatar.push(contactJid);
}
}
}
catch (err) {
logger_1.logger.error({ err, jid: c.id }, '[ContactHandler] Erro ao upsert contato');
}
}
logger_1.logger.debug({
instanceId: this.instanceId,
direct: directAvatarCount,
toFetch: needFetchAvatar.length,
}, '[ContactHandler] Avatares: diretos vs a buscar via API');
// Enfileira busca de avatares com cap de 50 por batch
// (randomiza para priorizar contatos diferentes a cada sync)
if (this.sock && needFetchAvatar.length > 0) {
const toQueue = needFetchAvatar.length > 50
? needFetchAvatar.sort(() => Math.random() - 0.5).slice(0, 50)
: needFetchAvatar;
this.enqueueAvatars(toQueue);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// UPDATE PARCIAL (contacts.update)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Atualiza campos parciais de contatos existentes.
*
* Dispara no evento contacts.update do Baileys — diferente do upsert,
* aqui só atualizamos os campos que foram explicitamente alterados.
* Não cria contatos novos (se não existir, ignora silenciosamente).
*/
async update(contacts) {
for (const c of contacts) {
if (!c.id)
continue;
const resolvedName = this.resolveName(c);
// Se nenhum campo relevante mudou, pula
if (!resolvedName && c.verifiedName === undefined && c.notify === undefined)
continue;
try {
await prisma_1.prisma.contact.updateMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: c.id,
},
data: {
...(resolvedName !== undefined && { name: resolvedName }),
...(c.verifiedName !== undefined && { verifiedName: c.verifiedName }),
...(c.notify !== undefined && { notify: c.notify }),
},
});
}
catch (err) {
logger_1.logger.error({ err, jid: c.id }, '[ContactHandler] Erro ao atualizar contato');
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// AVATAR DA INSTÂNCIA (foto de perfil do número da sessão)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Busca e salva a foto de perfil do número WhatsApp da instância.
* Chamado pelo WhatsAppConnectionManager após connection 'open'.
*/
async syncInstanceAvatar(instanceId) {
if (!this.sock?.user?.id)
return null;
try {
const url = await this.sock.profilePictureUrl(this.sock.user.id, 'image');
if (url) {
await prisma_1.prisma.instance.update({ where: { id: instanceId }, data: { avatar: url } });
logger_1.logger.info({ instanceId }, '[ContactHandler] Avatar da instância atualizado');
return url;
}
}
catch {
// Avatar privado ou não disponível — normal para contas com privacidade restrita
}
return null;
}
// ═══════════════════════════════════════════════════════════════════════════
// RECONCILIAÇÃO DE NOMES VIA PUSHNAME
// ═══════════════════════════════════════════════════════════════════════════
/**
* Reconcilia nomes de contatos após sync do histórico.
*
* PROBLEMA: O history sync do Baileys (messaging-history.set) traz contatos
* sem nome — apenas contas business (~22 de ~3000) vêm com verifiedName.
* Os pushNames só aparecem nas mensagens, não nos contatos do sync.
*
* SOLUÇÃO: Após o sync completo (isLatest=true), busca contatos com
* name=null E notify=null, e preenche usando o pushName da mensagem
* recebida mais recente para cada JID.
*
* FILTROS: Ignora @lid, @broadcast, @newsletter, @g.us (grupos usam subject)
*
* Emite evento `contacts:reconciled` para o frontend re-buscar a lista de chats.
*/
async reconcileNamesFromMessages() {
try {
// Busca contatos "sem nome" — candidatos à reconciliação
const unnamed = await prisma_1.prisma.contact.findMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
name: null,
notify: null,
NOT: [
{ jid: { endsWith: '@lid' } },
{ jid: { contains: '@broadcast' } },
{ jid: { endsWith: '@newsletter' } },
{ jid: { endsWith: '@g.us' } },
],
},
select: { id: true, jid: true },
});
if (unnamed.length === 0) {
logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] Reconciliação: todos os contatos já possuem nome');
return;
}
logger_1.logger.info({ instanceId: this.instanceId, count: unnamed.length }, '[ContactHandler] Reconciliação: buscando pushNames nas mensagens');
let updated = 0;
for (const contact of unnamed) {
// Busca a mensagem recebida mais recente com pushName para este JID
const msg = await prisma_1.prisma.message.findFirst({
where: {
instanceId: this.instanceId,
remoteJid: contact.jid,
fromMe: false,
pushName: { not: null },
},
orderBy: { timestamp: 'desc' },
select: { pushName: true },
});
if (msg?.pushName) {
await prisma_1.prisma.contact.update({
where: { id: contact.id },
data: { name: msg.pushName, notify: msg.pushName },
});
updated++;
}
}
logger_1.logger.info({ instanceId: this.instanceId, total: unnamed.length, updated }, '[ContactHandler] Reconciliação de nomes concluída');
// Notifica o frontend para atualizar a lista de chats
if (updated > 0) {
this.io?.to(`tenant:${this.tenantId}`).emit('contacts:reconciled', {
instanceId: this.instanceId,
count: updated,
});
}
}
catch (err) {
logger_1.logger.error({ err }, '[ContactHandler] Erro na reconciliação de nomes');
}
}
// ═══════════════════════════════════════════════════════════════════════════
// FORCE CONTACT SYNC (resyncAppState)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Força re-sync dos contatos via app-state do Baileys.
*
* NOTA: Este método existe mas NÃO é chamado automaticamente.
* Em reconexões, o Baileys não re-envia contacts.upsert. Este método
* força o resync das app-state collections que contêm contatos.
*
* PROBLEMA CONHECIDO: a collection 'regular_low' pode falhar com
* "tried remove, but no previous op" — erro de estado corrompido
* no Baileys. Por isso foi removido do trigger automático pós-conexão.
*
* Pode ser chamado manualmente via API se necessário.
*/
async forceContactSync() {
if (!this.sock)
return;
try {
logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] Forçando resync de contatos via app-state');
await this.sock.resyncAppState(['critical_unblock_low', 'regular_low', 'regular'], false);
logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] resyncAppState concluído');
}
catch (err) {
logger_1.logger.warn({ err, instanceId: this.instanceId }, '[ContactHandler] Erro no resyncAppState (não-fatal)');
}
}
// ═══════════════════════════════════════════════════════════════════════════
// SYNC PÓS-CONEXÃO: AVATARES AUSENTES
// ═══════════════════════════════════════════════════════════════════════════
/**
* Busca avatares ausentes para chats ativos após reconexão.
*
* Chamado pelo WhatsAppConnectionManager 5s após connection 'open'.
* Cobre o caso em que contacts.upsert não disparou (history sync timeout)
* ou contatos vieram sem imgUrl.
*
* Prioriza chats não-arquivados ordenados por lastMessageAt (mais recentes primeiro).
* Limitado a MAX_MISSING_AVATAR_SYNC (80) para não sobrecarregar a API do WA.
*/
async syncMissingAvatars() {
try {
const missing = await prisma_1.prisma.chat.findMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
isArchived: false,
NOT: [
{ jid: { endsWith: '@lid' } },
{ jid: { contains: '@broadcast' } },
],
contact: { avatarUrl: null },
},
select: { jid: true },
orderBy: { lastMessageAt: { sort: 'desc', nulls: 'last' } },
take: MAX_MISSING_AVATAR_SYNC,
});
if (missing.length === 0)
return;
const jids = missing.map((c) => c.jid);
logger_1.logger.info({ instanceId: this.instanceId, count: jids.length }, '[ContactHandler] Sync pós-conexão: buscando avatares ausentes');
this.enqueueAvatars(jids);
}
catch (err) {
logger_1.logger.error({ err }, '[ContactHandler] Erro no sync de avatares ausentes');
}
}
/**
* Re-faz profilePictureUrl para contatos cuja URL CDN do WhatsApp expira
* em menos de 48 horas ou já expirou.
*
* URLs do WA têm parâmetro `oe=<hex epoch>` que indica a expiração.
* Sem renovação, <img> quebra silenciosamente no browser após o TTL.
*/
async syncExpiredAvatars() {
try {
const contacts = await prisma_1.prisma.contact.findMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
avatarUrl: { not: null },
NOT: [
{ jid: { endsWith: '@lid' } },
{ jid: { contains: '@broadcast' } },
],
},
select: { jid: true, avatarUrl: true },
orderBy: { updatedAt: 'asc' },
take: 200,
});
const threshold = Math.floor(Date.now() / 1000) + 48 * 3600; // now + 48h
const expiring = contacts
.filter(c => {
const m = c.avatarUrl.match(/[?&]oe=([0-9a-f]+)/i);
if (!m)
return false;
return parseInt(m[1], 16) < threshold;
})
.map(c => c.jid);
if (expiring.length === 0)
return;
logger_1.logger.info({ instanceId: this.instanceId, count: expiring.length }, '[ContactHandler] Renovando avatares próximos de expirar');
// Limpa as URLs expiradas antes de re-buscar, para que o browser
// não use URLs inválidas enquanto a fila processa
await prisma_1.prisma.contact.updateMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: { in: expiring },
},
data: { avatarUrl: null },
});
this.enqueueAvatars(expiring.slice(0, MAX_MISSING_AVATAR_SYNC));
}
catch (err) {
logger_1.logger.error({ err }, '[ContactHandler] Erro no sync de avatares expirados');
}
}
// ═══════════════════════════════════════════════════════════════════════════
// FILA DE AVATARES COM RATE-LIMIT
// ═══════════════════════════════════════════════════════════════════════════
/**
* Adiciona JIDs à fila de busca de avatares (com deduplicação).
*
* A fila é processada em lotes de AVATAR_BATCH_SIZE com delay de
* AVATAR_DELAY_MS entre cada lote para respeitar o rate-limit do WhatsApp.
*
* Se a fila já está sendo processada, os novos JIDs são adicionados
* e serão processados na próxima iteração do loop.
*/
enqueueAvatars(jids) {
const inQueue = new Set(this.avatarQueue);
for (const jid of jids) {
if (!inQueue.has(jid))
this.avatarQueue.push(jid);
}
this.processAvatarQueue();
}
/**
* Processa a fila de avatares com rate-limit.
*
* Usa Promise.allSettled() para que falhas individuais (foto privada,
* JID inválido) não interrompam o lote. Cada avatar encontrado é:
* 1. Salvo no campo avatarUrl do contato no banco
* 2. Emitido via Socket.IO para atualizar o frontend em tempo real
*/
async processAvatarQueue() {
// Mutex simples: apenas uma execução por vez
if (this.processingAvatars || this.avatarQueue.length === 0)
return;
this.processingAvatars = true;
logger_1.logger.info({ count: this.avatarQueue.length, instanceId: this.instanceId }, '[ContactHandler] Iniciando sync de avatares');
while (this.avatarQueue.length > 0 && this.sock) {
// Pega o próximo lote (remove da fila)
const batch = this.avatarQueue.splice(0, AVATAR_BATCH_SIZE);
await Promise.allSettled(batch.map(async (jid) => {
try {
const url = await this.sock.profilePictureUrl(jid, 'image');
if (url) {
// Persiste no banco
await prisma_1.prisma.contact.updateMany({
where: { tenantId: this.tenantId, instanceId: this.instanceId, jid },
data: { avatarUrl: url },
});
// Notifica o frontend para atualizar o avatar na UI
this.io?.to(`tenant:${this.tenantId}`).emit('contact:avatar', {
instanceId: this.instanceId,
jid,
avatarUrl: url,
});
}
}
catch {
// Foto privada ou JID inválido — ignora silenciosamente
// (não loga para evitar spam com milhares de contatos)
}
}));
// Delay entre lotes para respeitar rate-limit do WhatsApp
if (this.avatarQueue.length > 0) {
await new Promise((r) => setTimeout(r, AVATAR_DELAY_MS * AVATAR_BATCH_SIZE));
}
}
this.processingAvatars = false;
logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] Sync de avatares concluído');
}
}
exports.ContactHandler = ContactHandler;
//# sourceMappingURL=ContactHandler.js.map