/** * ContactHandler — Gerencia contatos e avatares do WhatsApp. * * Responsabilidades: * 1. Upsert de contatos em massa (evento contacts.upsert do Baileys) * 2. Update parcial de contatos (evento contacts.update) * 3. Sincronização de avatares com rate-limit (fila com batch + delay) * 4. Reconciliação de nomes via pushName das mensagens (pós history sync) * 5. Mapeamento LID → phone JID (captura do campo `lid` nos contatos) * 6. Sincronização do avatar da própria instância * * CONTEXTO LID: * O Baileys pode enviar contatos com id no formato @lid (Linked ID). * Quando o contato inclui o campo `jid` (history sync), usamos o JID real * e armazenamos o LID em contacts.lidJid para resolução futura. * @see MessageHandler.resolveLidToPhoneJid() * * REGRA DE PRIORIDADE DE NOMES: * name (agenda do telefone) > verifiedName (conta business) > notify (pushname) * Essa hierarquia é respeitada tanto no upsert quanto na resolução pelo frontend. * * @see WhatsAppConnectionManager — registra os event listeners * @see MessageHandler — consome os contatos para vincular a mensagens */ import type { Contact, WASocket } from '../engine' import { prisma } from '../../../infra/database/prisma' import { logger } from '../../../config/logger' import type { Server as SocketIOServer } from 'socket.io' // ─── 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 export class ContactHandler { /** Fila de JIDs aguardando busca de avatar */ private avatarQueue: string[] = [] /** Flag de mutex simples — evita processamento concorrente da fila */ private processingAvatars = false constructor( /** UUID da instância WhatsApp */ private instanceId: string, /** UUID do tenant (isolamento multi-tenant) */ private tenantId: string, /** Socket Baileys — usado para profilePictureUrl e resyncAppState */ private sock?: WASocket, /** Socket.IO — emite eventos de avatar/reconciliação para o frontend */ private io?: SocketIOServer ) {} // ═══════════════════════════════════════════════════════════════════════════ // 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. */ private resolveName(c: Partial): string | undefined { 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: Contact[]): Promise { /** JIDs que precisam buscar avatar via profilePictureUrl */ const needFetchAvatar: string[] = [] /** 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 as any).imgUrl as string | null | undefined // 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 as any).lid as string | undefined 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 as any).jid as string | undefined ?? (c as any).phoneNumber as string | undefined 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.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.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.error({ err, jid: c.id }, '[ContactHandler] Erro ao upsert contato') } } 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: Partial[]): Promise { 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.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.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: string): Promise { if (!this.sock?.user?.id) return null try { const url = await this.sock.profilePictureUrl(this.sock.user.id, 'image') if (url) { await prisma.instance.update({ where: { id: instanceId }, data: { avatar: url } }) 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(): Promise { try { // Busca contatos "sem nome" — candidatos à reconciliação const unnamed = await 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.info({ instanceId: this.instanceId }, '[ContactHandler] Reconciliação: todos os contatos já possuem nome') return } 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.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.contact.update({ where: { id: contact.id }, data: { name: msg.pushName, notify: msg.pushName }, }) updated++ } } 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.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(): Promise { if (!this.sock) return try { 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.info({ instanceId: this.instanceId }, '[ContactHandler] resyncAppState concluído') } catch (err) { 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(): Promise { try { const missing = await 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.info({ instanceId: this.instanceId, count: jids.length }, '[ContactHandler] Sync pós-conexão: buscando avatares ausentes') this.enqueueAvatars(jids) } catch (err) { 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=` que indica a expiração. * Sem renovação, quebra silenciosamente no browser após o TTL. */ async syncExpiredAvatars(): Promise { try { const contacts = await 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.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.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.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: string[]): void { 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 */ private async processAvatarQueue(): Promise { // Mutex simples: apenas uma execução por vez if (this.processingAvatars || this.avatarQueue.length === 0) return this.processingAvatars = true 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.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.info({ instanceId: this.instanceId }, '[ContactHandler] Sync de avatares concluído') } }