560 lines
24 KiB
TypeScript
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')
|
|
}
|
|
}
|