Files
instrucoes_gerais/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/ContactHandler.ts
T

560 lines
24 KiB
TypeScript

/**
* 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<Contact>): 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<void> {
/** 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<Contact>[]): Promise<void> {
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<string | null> {
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<void> {
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<void> {
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<void> {
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=<hex epoch>` que indica a expiração.
* Sem renovação, <img> quebra silenciosamente no browser após o TTL.
*/
async syncExpiredAvatars(): Promise<void> {
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<void> {
// 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<void>((r) => setTimeout(r, AVATAR_DELAY_MS * AVATAR_BATCH_SIZE))
}
}
this.processingAvatars = false
logger.info({ instanceId: this.instanceId }, '[ContactHandler] Sync de avatares concluído')
}
}