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:
+74
-51
@@ -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')
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user