feat(avatar): sincroniza TODOS os avatares (remove caps de enfileiramento)

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 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Deploy Agent
2026-06-27 23:36:18 +02:00
parent b04f9cdafc
commit 93be34d463
@@ -35,8 +35,10 @@ import type { Server as SocketIOServer } from 'socket.io'
const AVATAR_BATCH_SIZE = 5 const AVATAR_BATCH_SIZE = 5
/** Delay entre lotes de avatares (~1 req/s por JID → abaixo do threshold do WA) */ /** Delay entre lotes de avatares (~1 req/s por JID → abaixo do threshold do WA) */
const AVATAR_DELAY_MS = 900 const AVATAR_DELAY_MS = 900
/** Máximo de avatares a buscar no sync pós-conexão (evita overload em contas grandes) */ /** Quantos contatos ler do banco por página ao varrer faltantes/expirados.
const MAX_MISSING_AVATAR_SYNC = 80 * 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 { export class ContactHandler {
/** Fila de JIDs aguardando busca de avatar */ /** Fila de JIDs aguardando busca de avatar */
@@ -203,13 +205,11 @@ export class ContactHandler {
toFetch: needFetchAvatar.length, toFetch: needFetchAvatar.length,
}, '[ContactHandler] Avatares: diretos vs a buscar via API') }, '[ContactHandler] Avatares: diretos vs a buscar via API')
// Enfileira busca de avatares com cap de 50 por batch // Enfileira TODOS os avatares pendentes — sem cap. A fila
// (randomiza para priorizar contatos diferentes a cada sync) // (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) { if (this.sock && needFetchAvatar.length > 0) {
const toQueue = needFetchAvatar.length > 50 this.enqueueAvatars(needFetchAvatar)
? needFetchAvatar.sort(() => Math.random() - 0.5).slice(0, 50)
: needFetchAvatar
this.enqueueAvatars(toQueue)
} }
} }
@@ -396,34 +396,51 @@ export class ContactHandler {
* Cobre o caso em que contacts.upsert não disparou (history sync timeout) * Cobre o caso em que contacts.upsert não disparou (history sync timeout)
* ou contatos vieram sem imgUrl. * ou contatos vieram sem imgUrl.
* *
* Prioriza chats não-arquivados ordenados por lastMessageAt (mais recentes primeiro). * Cobre TODOS os contatos sem avatar: primeiro os chats não-arquivados
* Limitado a MAX_MISSING_AVATAR_SYNC (80) para não sobrecarregar a API do WA. * (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<void> { async syncMissingAvatars(): Promise<void> {
try { try {
const missing = await prisma.chat.findMany({ const baseWhere = {
where: { tenantId: this.tenantId,
tenantId: this.tenantId, instanceId: this.instanceId,
instanceId: this.instanceId, NOT: [
isArchived: false, { jid: { endsWith: '@lid' } },
NOT: [ { jid: { contains: '@broadcast' } },
{ jid: { endsWith: '@lid' } }, ],
{ jid: { contains: '@broadcast' } }, }
],
contact: { avatarUrl: null }, // 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 }, select: { jid: true },
orderBy: { lastMessageAt: { sort: 'desc', nulls: 'last' } }, 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(
logger.info({ instanceId: this.instanceId, count: jids.length }, { instanceId: this.instanceId, chats: chats.length, contacts: scanned },
'[ContactHandler] Sync pós-conexão: buscando avatares ausentes') '[ContactHandler] Sync pós-conexão: enfileirando TODOS os avatares ausentes',
)
this.enqueueAvatars(jids)
} catch (err) { } catch (err) {
logger.error({ err }, '[ContactHandler] Erro no sync de avatares ausentes') logger.error({ err }, '[ContactHandler] Erro no sync de avatares ausentes')
} }
@@ -438,34 +455,40 @@ export class ContactHandler {
*/ */
async syncExpiredAvatars(): Promise<void> { async syncExpiredAvatars(): Promise<void> {
try { 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 threshold = Math.floor(Date.now() / 1000) + 48 * 3600 // now + 48h
const expiring = contacts const expiring: string[] = []
.filter(c => {
const m = c.avatarUrl!.match(/[?&]oe=([0-9a-f]+)/i) // Varre TODOS os contatos com avatarUrl (paginado), coletando os cuja URL
if (!m) return false // do CDN do WhatsApp (param oe=<hex>) expira em <48h. Paths internos
return parseInt(m[1], 16) < threshold // (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 if (expiring.length === 0) return
logger.info({ instanceId: this.instanceId, count: expiring.length }, 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 // Limpa as URLs expiradas antes de re-buscar, para que o browser
// não use URLs inválidas enquanto a fila processa // não use URLs inválidas enquanto a fila processa
@@ -478,7 +501,7 @@ export class ContactHandler {
data: { avatarUrl: null }, data: { avatarUrl: null },
}) })
this.enqueueAvatars(expiring.slice(0, MAX_MISSING_AVATAR_SYNC)) this.enqueueAvatars(expiring)
} catch (err) { } catch (err) {
logger.error({ err }, '[ContactHandler] Erro no sync de avatares expirados') logger.error({ err }, '[ContactHandler] Erro no sync de avatares expirados')
} }