Sincronización de avatares WhatsApp¶
qrsgen mantiene el avatar de cada contacto y grupo en el downstream sincronizado con la foto de perfil real de WhatsApp. Reemplaza los letter-avatars autogenerados por Chatwoot (o equivalente) por la foto que la persona o el admin del grupo tiene configurada en WhatsApp.
Read-only sobre WhatsApp: qrsgen solo lee fotos de perfil (
GetProfilePictureInfo+ HTTP GET). Nunca escribe en el perfil del usuario. La escritura es solo hacia el downstream.
TL;DR¶
| Capa | Cuándo dispara | Coste | Versión |
|---|---|---|---|
| On-create | CreateContact exitoso en downstream |
1 GET metadata + 1 GET imagen + 1 PUT downstream | v0.31.0 |
| TTL refresh | Siguiente mensaje del JID tras TTL | 1 GET metadata (+ descarga solo si cambió ID) | v0.31.1 |
| Real-time | events.Picture de whatsmeow (usuario cambia su foto) |
1 GET metadata + 1 GET imagen + 1 PUT downstream | v0.31.2 |
| Bulk re-sync | Operador llama POST .../avatars/resync |
Una pasada por todos los contactos del inbox | v0.31.3 |
Todas las capas son fire-and-forget: corren en goroutines separadas
y nunca bloquean el flujo del mensaje. Los errores (foto privada,
upload rechazado, etc.) se loguean como warn y se ignoran.
Configuración¶
| Env var | Default | Descripción |
|---|---|---|
QRSGEN_AVATAR_SYNC |
true |
Master switch. Si false, ninguna capa corre — los contactos quedan con su letter-avatar. |
QRSGEN_AVATAR_REFRESH_TTL |
24h |
TTL del tracker para refresh por mensaje. 0 desactiva el refresh (mantiene solo on-create y events.Picture). |
Ambas son opt-out: el comportamiento por defecto sincroniza fotos.
Capa 1 — sync al crear contacto (v0.31.0)¶
En bridge.sync(), justo después de un CreateContact exitoso en
downstream, qrsgen spawnea una goroutine que:
- Llama a
WAResolver.GetProfilePictureID(ctx, jid)— metadata only, no descarga la imagen. - Si devuelve
""→ el JID no tiene foto configurada. CacheaLastID=""en el tracker y termina. - Si devuelve un ID distinto del cacheado → llama
GetProfilePicture(ctx, jid)que hace un HTTP GET a la URL devuelta porclient.GetProfilePictureInfo. - Sube vía
Client.UploadContactAvatar(ctx, contactID, data, mime)— PUT multipart a/api/v1/accounts/X/contacts/Ycon el campoavatar. - Actualiza el tracker con el ID nuevo.
Aplica tanto a 1-on-1 (foto del usuario) como a grupos (foto del grupo). WhatsApp expone la foto del grupo bajo el mismo método.
Capa 2 — refresh con TTL (v0.31.1)¶
El tracker en memoria (internal/bridge/avatar_tracker.go) vive en el
proceso qrsgen y mantiene, por instancia y por JID:
LastChecked— timestamp del último chequeo.LastID— últimoinfo.IDcacheado (vacío = sin foto).
En cada mensaje incoming, sync() también llama maybeAvatarSync
para contactos existentes. El gating es:
ShouldCheck es atómico — si now - LastChecked > TTL, bumpea el
timestamp y devuelve true. Esto evita que un burst de mensajes del
mismo JID spawnee múltiples sync simultáneos (verificado en tests con
20 goroutines concurrentes; solo una ve true).
El check interno del sync usa el ID:
GetProfilePictureID(metadata, cheap) → comparar conLastID.- Si idénticos → no descarga, solo registra el check.
- Si distintos → descarga + sube +
UpdateIDcon el nuevo.
Resultado: para un contacto activo con foto estable, el coste de mantenerlo sincronizado es 1 GET metadata por TTL (HTTPS call sobre el WebSocket whatsmeow, no descarga del bitmap).
Capa 3 — refresh en tiempo real vía events.Picture (v0.31.2)¶
whatsmeow emite *events.Picture cuando un usuario o admin de grupo
cambia su foto. qrsgen subscribe vía:
mgr.SetPictureHandler(func(instance string, jid types.JID, pictureID string, removed bool, r wameow.WAResolver) {
incoming.HandlePictureChange(ctx, instance, jid, pictureID, removed, r)
})
HandlePictureChange:
- Resuelve el
downstream.ClientyRouterpor instancia. - Busca el contact con
FindContact(jid). Si no existe, sale (el primer mensaje del JID disparará el sync inicial vía capa 1). - Resetea
LastIDen el tracker (forzar re-descarga aunque el ID coincida por algún caché stale). - Spawnea
syncAvatarque descarga el nuevo bitmap y lo sube.
Resultado: cambio de foto en el móvil → avatar en Chatwoot actualizado en ~1s sin esperar al siguiente mensaje, sin esperar al TTL del tracker.
Manager.SetPictureHandler propaga el handler a todas las Conn
(existentes en el momento de la llamada + futuras creadas en
startLocked). Llamar antes de Bootstrap para capturar las
instancias auto-reconnect.
Capa 4 — bulk re-sync para backfill (v0.31.3)¶
Las capas 1-3 cubren contactos creados o que reciben mensajes después de adoptar la feature. Para contactos viejos (creados antes de v0.31.x o inactivos), hay un endpoint que itera todo el inbox.
Endpoint¶
Comportamiento¶
- Resuelve el
inbox_idde la instancia. - Llama
Client.ListContactsByInbox(ctx, inboxID, page)paginando desde page 1. - Por cada contacto cuyo
identifierparsea como JID válido: - Resetea
LastIDen el tracker (bypass del check ID-idéntico). - Spawnea
syncAvatar. - Cap defensivo: máximo 200 páginas (~3000 contactos) para no colgar el proceso si el inbox tiene datos absurdos.
Ejemplo¶
curl -X POST \
-H "Authorization: Bearer $QRSGEN_API_TOKEN" \
http://qrsgen:3100/api/instances/whatsapp-main/avatars/resync
Respuesta¶
| Campo | Significado |
|---|---|
scanned |
Contactos totales devueltos por el downstream. |
skipped |
Contactos cuyo identifier no parsea como JID válido (placeholders, sintéticos). |
queued |
Goroutines syncAvatar lanzadas. |
pages |
Páginas iteradas hasta encontrar la última (o el cap). |
queued es contadores de spawn, no de éxito real — los fallos
individuales se loguean pero no se cuentan en la respuesta. Para
auditar el resultado mira los logs (avatar synced info / avatar
sync: ... failed warn).
Cuándo usarlo¶
- Tras la primera adopción de v0.31.x sobre una instancia con muchos contactos pre-existentes.
- Si limpiaste avatares manualmente en el downstream y quieres rehidratarlos.
- Tras un cambio de
inbox_idque dejó contactos huérfanos sin sync histórico.
No tiene sentido llamarlo periódicamente — las capas 2 y 3 lo cubren de forma incremental sin la pasada bulk.
Modos de fallo¶
Todos los errores se loguean como warn y se ignoran. Nunca
bloquean la entrega del mensaje ni el flujo principal de sync().
| Situación | Log | Efecto en downstream |
|---|---|---|
| JID sin foto | (silencio — caso esperado) | Letter-avatar sigue visible. Tracker cachea LastID="". |
| Foto privada / account restringida | avatar sync: get id failed o download failed |
Letter-avatar sigue visible. Tracker bumpeó timestamp — reintento en el próximo TTL. |
| HTTP GET de la URL Meta falla | avatar sync: download failed |
Sin cambios. Reintento en próximo TTL. |
UploadContactAvatar falla (4xx/5xx Chatwoot) |
avatar sync: upload to downstream failed |
Avatar previo (si existía) intacto. Tracker NO actualiza LastID — reintento en próximo TTL. |
events.Picture para JID no existente en downstream |
(silencio en HandlePictureChange → return early) |
Sin cambios. Se sincronizará vía capa 1 al primer mensaje. |
| Bulk re-sync sobre inbox vacío | (silencio) | Respuesta {scanned: 0, ...}. |
Caveats y limitaciones¶
- Tracker in-memory. Restart del proceso = tracker vacío. En el
worst case, los primeros mensajes tras restart provocan una ronda
extra de chequeos
GetProfilePictureID(1 metadata call por JID activo dentro del TTL). No re-descargan la imagen si el ID no cambió. Es deliberado — evita migración DB para una optimización marginal. info.IDno es un hash universal. Es un identificador opaco que WhatsApp asigna a cada versión de la foto. Cambios de foto sí generan ID nuevos; reposiciones triviales también. No es viable comparar fotos por contenido.- Sin garantías de orden. Si llegan dos
events.Picturepara el mismo JID en ráfaga (raro pero posible), ambas goroutines de sync pueden correr en paralelo. La últimaUploadContactAvatarque termine gana. Suficiente para el caso real (el usuario cambia la foto, la sincronizamos a su nuevo valor). - Sin retry exponencial. Si Chatwoot devuelve 5xx, no hay backoff
— el próximo intento es en el siguiente TTL (o cuando el usuario
cambie de foto). Para garantías más fuertes, llama
/avatars/resyncmanualmente. - Rate limits del downstream. El bulk re-sync puede generar muchas requests concurrentes a Chatwoot. No hay throttle interno (las goroutines spawnean libremente). Si Chatwoot lo rate-limit-ea, los uploads fallarán con warn — vuelve a llamar pasada una pausa.
- Grupos: el avatar del grupo viene del lado WhatsApp. Si tu
downstream usa
identifier=<groupJID>con@g.us, el sync funciona igual que con 1-on-1. - No elimina avatares. Si
events.Picturellega conremoved=trueypictureID="", qrsgen cacheaLastID=""pero no borra el avatar previo en downstream — queda el último conocido. Decisión consciente: borrar avatar via Chatwoot API requiere otro endpoint y el caso "usuario quitó la foto" es raro vs. ruido.
Verificar que funciona¶
Tras enviar un mensaje desde un número con foto:
# Logs del container
docker logs qrsgen 2>&1 | grep "avatar synced"
# → time=... msg="avatar synced" contact_id=42 size=18432 mime=image/jpeg avatar_id=... jid=34600000000@s.whatsapp.net
En Chatwoot, abre el contacto → el avatar circular ya debería mostrar la foto en lugar de las iniciales.
Si no aparece nada:
- ¿Logs muestran
avatar sync: ... failed? → mira el campoerr. - ¿
QRSGEN_AVATAR_SYNC=false? → master switch off. - ¿El JID tiene foto pública en WhatsApp? Algunos perfiles la tienen restringida a contactos. qrsgen ve lo que WhatsApp expone al número conectado.
Glosario¶
Letter-avatar: avatar por defecto generado por el downstream (ej. Chatwoot) con las iniciales del nombre cuando no hay imagen subida. El avatar sync lo reemplaza por la foto real de WA.
info.ID: identificador opaco que WhatsApp asigna a cada versión
de la foto de perfil. Cambia cada vez que el usuario sube una foto
nueva. qrsgen lo usa para detectar cambios sin descargar el bitmap.
Tracker: estructura in-memory (per-instancia, per-JID) que
recuerda el último info.ID cacheado y cuándo se chequeó por última
vez. Define la cadencia del refresh con TTL.
Fire-and-forget: patrón donde una operación se lanza en goroutine sin esperar su resultado. Los errores se loguean pero no se propagan al caller. Apropiado cuando el éxito del sync no es crítico para el flow principal (la entrega del mensaje).
Bulk re-sync: pasada completa sobre todos los contactos de un inbox para forzar el sync del avatar. Útil como backfill puntual, no como mecanismo periódico.
events.Picture: evento que whatsmeow emite cuando alguien
(usuario o admin de grupo) cambia su foto de perfil. qrsgen lo
subscribe para sync en tiempo real.
GetProfilePictureID vs GetProfilePicture: el primero devuelve
solo el ID (metadata), el segundo descarga la imagen completa.
qrsgen prefiere el primero para decidir si hace falta el segundo.