From 93be34d46336c8b0e395c02e5ea351378c8defda Mon Sep 17 00:00:00 2001 From: VPS 4 Deploy Agent Date: Sat, 27 Jun 2026 23:36:18 +0200 Subject: [PATCH] feat(avatar): sincroniza TODOS os avatares (remove caps de enfileiramento) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Os avatares "não vinham todos" porque o enfileiramento era capado em 3 pontos, embora a fila (processAvatarQueue) já seja rate-limited e processe tudo que entra: - upsert: cap de 50 por batch (randomizado) -> enfileira todos os pendentes - syncMissingAvatars: take 80 -> cobre TODOS (chats recentes primeiro + varredura paginada de todos os contatos sem avatar) - syncExpiredAvatars: take 200 + slice 80 -> varre paginado e renova todos os avatares com URL do CDN do WhatsApp expirando (<48h) A cobertura total não dispara ban: a fila mantém ~1 req/s (lotes + delay); apenas leva mais tempo, em background. Co-Authored-By: Claude Opus 4.8 --- .../whatsapp/handlers/ContactHandler.ts | 125 +++++++++++------- 1 file changed, 74 insertions(+), 51 deletions(-) diff --git a/newwhats.clube67.com/newwhats.local/backend/src/modules/whatsapp/handlers/ContactHandler.ts b/newwhats.clube67.com/newwhats.local/backend/src/modules/whatsapp/handlers/ContactHandler.ts index 455f6f5..6bc8430 100644 --- a/newwhats.clube67.com/newwhats.local/backend/src/modules/whatsapp/handlers/ContactHandler.ts +++ b/newwhats.clube67.com/newwhats.local/backend/src/modules/whatsapp/handlers/ContactHandler.ts @@ -35,8 +35,10 @@ import type { Server as SocketIOServer } from 'socket.io' 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 +/** Quantos contatos ler do banco por página ao varrer faltantes/expirados. + * NÃO limita a cobertura: a varredura pagina até cobrir TODOS os contatos. + * Só evita carregar dezenas de milhares de linhas de uma vez na memória. */ +const AVATAR_SCAN_PAGE = 500 export class ContactHandler { /** Fila de JIDs aguardando busca de avatar */ @@ -203,13 +205,11 @@ export class ContactHandler { 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) + // Enfileira TODOS os avatares pendentes — sem cap. A fila + // (processAvatarQueue) é rate-limited (lotes + delay ~1 req/s), então cobrir + // todos os contatos não dispara ban: apenas leva mais tempo, em background. if (this.sock && needFetchAvatar.length > 0) { - const toQueue = needFetchAvatar.length > 50 - ? needFetchAvatar.sort(() => Math.random() - 0.5).slice(0, 50) - : needFetchAvatar - this.enqueueAvatars(toQueue) + this.enqueueAvatars(needFetchAvatar) } } @@ -396,34 +396,51 @@ export class ContactHandler { * 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. + * Cobre TODOS os contatos sem avatar: primeiro os chats não-arquivados + * (mais recentes primeiro, p/ a inbox), depois o restante paginado. Sem cap — + * a fila (processAvatarQueue) é rate-limited, então não sobrecarrega o 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 }, - }, + const baseWhere = { + tenantId: this.tenantId, + instanceId: this.instanceId, + NOT: [ + { jid: { endsWith: '@lid' } }, + { jid: { contains: '@broadcast' } }, + ], + } + + // 1) PRIORIDADE: chats não-arquivados sem avatar, mais recentes primeiro — + // para a inbox preencher rápido o que o usuário vê de imediato. + const chats = await prisma.chat.findMany({ + where: { ...baseWhere, isArchived: false, contact: { avatarUrl: null } }, select: { jid: true }, orderBy: { lastMessageAt: { sort: 'desc', nulls: 'last' } }, - take: MAX_MISSING_AVATAR_SYNC, }) + if (chats.length > 0) this.enqueueAvatars(chats.map((c) => c.jid)) - if (missing.length === 0) return + // 2) COBERTURA TOTAL: todos os demais contatos sem avatar, paginado. + // A fila deduplica (Set), então não re-busca os já enfileirados acima. + let scanned = 0 + for (let skip = 0; ; skip += AVATAR_SCAN_PAGE) { + const page = await prisma.contact.findMany({ + where: { ...baseWhere, avatarUrl: null }, + select: { jid: true }, + orderBy: { updatedAt: 'desc' }, + take: AVATAR_SCAN_PAGE, + skip, + }) + if (page.length === 0) break + this.enqueueAvatars(page.map((c) => c.jid)) + scanned += page.length + if (page.length < AVATAR_SCAN_PAGE) break + } - 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) + logger.info( + { instanceId: this.instanceId, chats: chats.length, contacts: scanned }, + '[ContactHandler] Sync pós-conexão: enfileirando TODOS os avatares ausentes', + ) } catch (err) { logger.error({ err }, '[ContactHandler] Erro no sync de avatares ausentes') } @@ -438,34 +455,40 @@ export class ContactHandler { */ 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 + const expiring: string[] = [] + + // Varre TODOS os contatos com avatarUrl (paginado), coletando os cuja URL + // do CDN do WhatsApp (param oe=) expira em <48h. Paths internos + // (Wasabi/local, sem oe) não expiram e são ignorados pelo filtro. + for (let skip = 0; ; skip += AVATAR_SCAN_PAGE) { + const page = 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: AVATAR_SCAN_PAGE, + skip, }) - .map(c => c.jid) + if (page.length === 0) break + for (const c of page) { + const m = c.avatarUrl!.match(/[?&]oe=([0-9a-f]+)/i) + if (m && parseInt(m[1], 16) < threshold) expiring.push(c.jid) + } + if (page.length < AVATAR_SCAN_PAGE) break + } if (expiring.length === 0) return logger.info({ instanceId: this.instanceId, count: expiring.length }, - '[ContactHandler] Renovando avatares próximos de expirar') + '[ContactHandler] Renovando TODOS os 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 @@ -478,7 +501,7 @@ export class ContactHandler { data: { avatarUrl: null }, }) - this.enqueueAvatars(expiring.slice(0, MAX_MISSING_AVATAR_SYNC)) + this.enqueueAvatars(expiring) } catch (err) { logger.error({ err }, '[ContactHandler] Erro no sync de avatares expirados') }