feat: sincronização de desenvolvimento local da VPS 4 para o Gitea
This commit is contained in:
@@ -0,0 +1,559 @@
|
||||
/**
|
||||
* 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')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user