Presencia (typing) y read receipts¶
A partir de v0.34.0, v0.34.1 y v0.39.0, qrsgen propaga señales de interacción en tiempo real que antes se descartaban — ahora en ambas direcciones:
- v0.34.0 — eventos de presencia de chat (
composing/paused) WA → downstream: cuando el cliente WhatsApp está escribiendo, el agente ve el indicador "está escribiendo..." en el panel del downstream. - v0.34.1 — read receipts WA → downstream: cuando el cliente abre
el chat y lee los mensajes del agente, qrsgen actualiza
contact_last_seen_atde la conversación y la UI marca los mensajes del agente como leídos (equivalente al doble tick azul). - v0.39.0 — mark-as-read downstream → WA: cuando el agente abre
la conversación en Chatwoot y marca los mensajes como leídos, qrsgen
envía
MarkReada WhatsApp para que el cliente remoto vea el doble check azul sobre sus mensajes incoming. Cierra el ciclo bidireccional.
WhatsApp ya no es read-only para receipts: desde v0.39.0 qrsgen escribe
MarkReadde vuelta a WA (única excepción). Sigue sin enviar presencia (typing) ni edición de perfil — el resto del estado WA permanece read-only.
TL;DR¶
| Feature | Dirección | Evento de origen | Acción | Env var | Versión |
|---|---|---|---|---|---|
| Typing indicator | WA → downstream | *events.ChatPresence (composing / paused) |
POST .../conversations/Y/toggle_typing_status body {"typing_status":"on"\|"off"} |
QRSGEN_TYPING_SYNC (default true) |
v0.34.0 |
| Read receipt (incoming) | WA → downstream | *events.Receipt (kind in ("read","read-self")) |
POST .../conversations/Y/update_last_seen body {agent_last_seen_at, contact_last_seen_at} |
QRSGEN_READ_RECEIPTS_SYNC (default true) |
v0.34.1 |
| Mark-as-read (outgoing) | downstream → WA | Webhook conversation_updated con agent_last_seen_at |
wameow.Conn.MarkRead(chat, sender, messageIDs, ts) sobre client.MarkRead() de whatsmeow |
QRSGEN_MARK_AS_READ_OUTGOING (default true) |
v0.39.0 |
Las tres son opt-out y fire-and-forget: ningún fallo bloquea el flujo
principal. Comparten arquitectura platform-agnostic: callbacks en
wameow.Conn, propagación vía manager.Set*Handler, dispatch a
bridge.Incoming / bridge.Outgoing, y POSTs vía downstream.Client
para los dos primeros / MarkRead de whatsmeow para el tercero.
Typing indicators (v0.34.0)¶
Configuración¶
| Env var | Default | Descripción |
|---|---|---|
QRSGEN_TYPING_SYNC |
true |
Master switch. Si false, los eventos ChatPresence se descartan silenciosamente. |
Cómo se dispatcha¶
- whatsmeow emite
*events.ChatPresencecuando el cliente remoto empieza o deja de escribir. wameow.Connejecuta el callback registrado víaSetChatPresenceHandler.manager.SetChatPresenceHandlerlo propaga a todas lasConnactuales y a las que se creen enstartLocked(mismo patrón queSetPictureHandleren avatar sync).- El dispatcher entra en el case nuevo y llama a
bridge.Incoming.HandleChatPresence(ctx, instance, jid, state). HandleChatPresence:- Busca contacto + conversación con
FindContact/FindConversation. No crea nada — si no existen, descarta el evento. - Consulta el
typingTrackerpara decidir si emite o silencia. - Si emite: llama
Client.SetTypingStatus(convID, typing bool)que hacePOST /api/v1/accounts/X/conversations/Y/toggle_typing_statuscon body{"typing_status":"on"}o{"typing_status":"off"}.
Throttle (typingTracker)¶
El tracker (internal/bridge/typing_tracker.go) es in-memory,
per-conversación, y deduplica llamadas redundantes al downstream:
- Cambio de estado (typing → not typing, o viceversa): siempre emite. No queremos perder transiciones reales.
- Mismo estado dentro de
minInterval(default 4s): NO emite. El cliente WhatsApp puede repetircomposingcada vez que el usuario pulsa una tecla — sin throttle inundaríamos el downstream con POSTs idénticos. - Mismo estado fuera de
minInterval: emite (recordatorio al downstream de que la sesión sigue "viva").
5 tests cubren las combinaciones (estado distinto, estado igual dentro del intervalo, estado igual fuera del intervalo, primera llamada, reset).
El estado del tracker es per-conversación, in-memory, y se pierde en restart. Worst case tras restart: una llamada HTTP extra al downstream en el primer evento de cada conversación activa.
Formato en el downstream¶
Chatwoot (y compatibles) muestra "está escribiendo..." debajo de la
caja de input cuando recibe typing_status: "on". Al recibir "off"
(o tras un timeout interno del downstream) el indicador desaparece.
qrsgen propaga ambos estados explícitamente — no confía en el timeout
implícito del downstream.
Modos de fallo¶
| Situación | Resultado |
|---|---|
QRSGEN_TYPING_SYNC=false |
Evento descartado silenciosamente. No se loguea por evento (sería ruido). |
| Contacto no existe en downstream | Evento descartado. No creamos contactos por una notificación de typing. El primer mensaje "real" abrirá la ruta. |
| Conversación no abierta para el contacto | Igual: descartado. |
Mismo estado dentro de minInterval |
Throttled. No hay POST. |
SetTypingStatus falla (4xx/5xx) |
Log warn. No retry — el siguiente evento intentará de nuevo. |
Read receipts (v0.34.1)¶
Configuración¶
| Env var | Default | Descripción |
|---|---|---|
QRSGEN_READ_RECEIPTS_SYNC |
true |
Master switch. Si false, los *events.Receipt se ignoran sin POST al downstream. |
Qué se propaga¶
bridge.Incoming.HandleReceipt filtra por receipt.Type:
| Tipo de receipt | ¿Propagado? | Motivo |
|---|---|---|
read |
Sí | El cliente abrió el chat y leyó los mensajes del agente. Es la señal accionable. |
read-self |
Sí | Lectura desde otro device Multi-Device del propio usuario. Misma semántica para el agente. |
delivered |
No | El servidor de Meta confirmó entrega, pero el cliente no necesariamente abrió el chat. Poco actionable. |
played |
No | Audio reproducido. Específico a media; el downstream no tiene UI estándar para "audio escuchado". |
sender |
No | Confirmación de envío. Redundante para el flujo incoming. |
Tipos no listados se ignoran por construcción (default del switch).
Cómo se propaga¶
- whatsmeow emite
*events.Receipt. Dispatcher entra en el case nuevo y llama abridge.Incoming.HandleReceipt. - Filtra por
Type in ("read", "read-self"). Resto: return early. - Resuelve contacto + conversación. Si no existen, descarta.
- Llama
Client.UpdateContactLastSeen(convID, ts):
POST /api/v1/accounts/X/conversations/Y/update_last_seen
{
"agent_last_seen_at": <unix ts>,
"contact_last_seen_at": <unix ts>
}
Ambos campos llevan el mismo timestamp: el receipt.Timestamp (Unix
epoch en segundos). Chatwoot espera ambos para considerar la
conversación "vista por el contacto" en su modelo interno.
Correlación de timestamps¶
El timestamp del POST viene de receipt.Timestamp — el momento en que
WhatsApp registró el receipt, no el momento en que qrsgen recibe el
evento. Si hay lag de WebSocket (segundos), el contact_last_seen_at
sigue siendo precisa respecto a cuándo el contacto realmente leyó el
mensaje.
Modos de fallo¶
| Situación | Resultado |
|---|---|
QRSGEN_READ_RECEIPTS_SYNC=false |
Evento descartado silenciosamente. |
Tipo distinto de read/read-self |
Ignorado por filtro. |
| Contacto/conversación no existen en downstream | Descartado. No creamos nada por un receipt aislado. |
UpdateContactLastSeen falla (4xx/5xx) |
Log warn. No retry — el próximo receipt para esa conv corregirá el valor. |
Mark-as-read outgoing (v0.39.0)¶
Cierra el ciclo bidireccional. Mientras v0.34.1 propaga las lecturas
del cliente WhatsApp hacia el downstream (contact_last_seen_at),
v0.39.0 hace el camino inverso: cuando el agente lee la conversación en
Chatwoot, qrsgen envía MarkRead a WhatsApp para que el cliente
remoto vea el doble check azul sobre los mensajes que envió.
Configuración¶
| Env var | Default | Descripción |
|---|---|---|
QRSGEN_MARK_AS_READ_OUTGOING |
true |
Master switch. Si false, los webhooks conversation_updated se ignoran sin llamar a MarkRead. |
Configuración REQUERIDA en el downstream¶
Para que el feature funcione, en Chatwoot:
- El webhook ya configurado contra
POST /api/instances/{name}/webhookde qrsgen es el mismo endpoint que paramessage_created. No se añade URL nueva. - ADEMÁS del evento
message_created, hay que activar el eventoconversation_updateden la configuración del webhook de Chatwoot.
Sin esa suscripción el feature no hace nada — no rompe nada, pero qrsgen nunca recibe la señal de cuándo el agente leyó. Cliente WA seguirá viendo doble tick gris.
Flujo¶
- Cliente envía mensaje incoming → qrsgen lo postea al downstream (path normal de incoming).
- Tras
PostMessageexitoso, qrsgen registra el WAID del mensaje en un tracker in-memory (waidTracker) per-conversación, junto con su timestamp. - El agente abre la conversación en Chatwoot y la marca como leída.
Chatwoot actualiza internamente
agent_last_seen_at. - Chatwoot dispara el webhook
conversation_updatedcontra qrsgen, incluyendo el nuevoagent_last_seen_at. - qrsgen drena del tracker todos los WAIDs cuyo timestamp es
≤ agent_last_seen_at, los agrupa por(chat, sender)y llamawameow.Conn.MarkRead(ctx, chat, sender, messageIDs, ts)que envuelveclient.MarkRead()de whatsmeow. - El cliente WA ve el doble check azul sobre todos sus mensajes incluidos en ese batch.
Cómo se cablea internamente¶
wameow.Conn.MarkRead(ctx, chat, sender, messageIDs, ts)es un wrapper fino sobreclient.MarkRead(). Idempotente: una segunda llamada con los mismos IDs no produce efecto adicional.bridge.waidTracker(archivo nuevo) es un tracker in-memory per-conversación con cap por defecto de 50 entradas/conv (FIFO al desbordar). Métodos:RecordIncoming(convID, waid, ts)yDrainBefore(convID, cutoffTS).bridge.ReadMarkeres una interfaz pequeña con un solo método, desacoplabridge.Outgoingdewameow.Conny permite testear sin cliente WA real.bridge.Outgoing.EnableMarkAsRead(waids *waidTracker, marker ReadMarker)cablea los componentes enmain.go.bridge.Incoming.EnableMarkAsRead()devuelve un*waidTrackerrecién construido —IncomingyOutgoingcomparten el mismo tracker. Elsync()de incoming llamatracker.RecordIncomingtras unPostMessageexitoso.WebhookPayload.Conversation.AgentLastSeenAtyContactLastSeenAtson campos nuevos en el payload del webhook que qrsgen parsea.senderAdapter.MarkReadenmain.gopuentea haciawameow.Conn.MarkRead.
Tests¶
Cuatro tests cubren waidTracker:
RecordIncoming+DrainBeforecon cutoff exacto (incluye y excluye según≤).- Cap de 50: el 51º entry expulsa el más viejo (FIFO).
- Aislamiento per-conversación: drenar
convAno afectaconvB. - Conversación vacía:
DrainBeforedevuelve slice vacío sin error.
Modos de fallo¶
| Situación | Resultado |
|---|---|
QRSGEN_MARK_AS_READ_OUTGOING=false |
Webhook conversation_updated ignorado silenciosamente. |
Evento conversation_updated sin agent_last_seen_at (o =0) |
Ignorado. Chatwoot emite conversation_updated para muchas cosas (cambio de etiqueta, asignación de agente, status); solo procesamos los que traen lectura real. |
| Tracker vacío para esa conv (drainada previamente o restart reciente) | DrainBefore devuelve []. No se llama MarkRead. |
client.MarkRead() falla (instance disconnected, etc.) |
Log warn. No retry — la siguiente conversation_updated reintentará (si todavía hay WAIDs en el tracker). |
| Llamada duplicada con mismo cutoff | Idempotente. Los WAIDs ya drenados no se vuelven a marcar. |
Verificar que funciona¶
Cuando el agente lee una conversación en Chatwoot:
docker logs qrsgen 2>&1 | grep "mark-as-read sent to WhatsApp"
# → time=... msg="mark-as-read sent to WhatsApp" instance=...
# conv_id=42 chat=...@s.whatsapp.net count=3 cutoff_ts=1716889200
En el móvil del cliente WA, los mensajes incluidos en el batch deberían mostrar doble check azul.
Si no aparece nada:
- ¿
QRSGEN_MARK_AS_READ_OUTGOING=false? - ¿Está activado el evento
conversation_updateden el webhook de Chatwoot? Si solo estámessage_created, qrsgen nunca recibe la señal. - ¿Reciente restart de qrsgen? El tracker es in-memory — los WAIDs registrados antes del restart se pierden y no se pueden marcar retroactivamente (ver caveats).
Caveats y edge cases¶
- Grupos: typing per participante, un solo indicador. Si tres
miembros del grupo escriben a la vez, whatsmeow emite tres
ChatPresencecon JIDs distintos pero el mismoChat. El downstream solo soporta un indicador por conversación, así que el agente leerá "alguien está escribiendo..." sin saber quién. Es una limitación del modelo del downstream, no de qrsgen. - Privacy settings ocultan receipts. Si el sender desactivó las
confirmaciones de lectura en su WhatsApp (Ajustes → Cuenta →
Privacidad → Confirmaciones de lectura), Meta no envía
*events.ReceiptconType=readpara sus mensajes. qrsgen recibe la ausencia silenciosa —contact_last_seen_atse queda obsoleto. La cobertura es parcial: depende de la configuración del cliente remoto. - In-memory state, no persistencia. El
typingTrackervive en memoria. Restart del proceso = throttle resetea. Worst case: una llamada HTTP extra al downstream por conversación activa inmediatamente tras restart. Deliberado — no merece migración DB. MarkReadback-to-WhatsApp en v0.39.0. Desde v0.39.0 el agente leyendo en Chatwoot sí propagaMarkReada WhatsApp (doble check azul al cliente remoto). Requiere suscribir el eventoconversation_updateden el webhook del downstream — sin esa config el feature no opera. Ver sección Mark-as-read outgoing (v0.39.0).- Tracker mark-as-read in-memory. El
waidTracker(v0.39.0) vive en memoria igual que eltypingTracker. Restart de qrsgen pierde el historial: los mensajes incoming registrados antes del restart no se podrán marcar como leídos en WA aunque el agente los lea después. El efecto es cosmético — solo afecta al doble check azul en mensajes viejos. El cap de 50 WAIDs/conv también puede expulsar los más viejos en conversaciones muy activas, peroMarkReadsolo importa para los recientes. conversation_updatedsinagent_last_seen_atse ignora. Chatwoot emite ese webhook por muchos motivos (cambio de etiqueta, asignación de agente, status...). qrsgen solo procesa los que traen unagent_last_seen_at > 0, evitando reintentos espurios.- Idempotencia accidental del mark-as-read. Si Chatwoot emite dos
conversation_updatedcon el mismoagent_last_seen_at, el segundo no produce efecto: los WAIDs ya drenados no vuelven al tracker. No es un fallo — es una propiedad deseable. - Sin reintento exponencial. Igual que en avatar sync y reactions
sync: si el downstream devuelve 5xx, no hay backoff — el próximo
evento intentará de nuevo. Para read receipts esto es benigno porque
el siguiente
readcorrige ellast_seen_at; para typing el evento se pierde sin más (el agente verá menos transiciones). - Throttle compartido entre transiciones rápidas. Si el usuario
empieza a escribir, para a los 2s, y vuelve a escribir, qrsgen emite
cada cambio porque son estados distintos. Si escribe
continuamente durante 10s, solo emite al inicio (composing) y al
final (paused) — los
composingrepetidos del medio quedan throttled.
Verificar que funciona¶
Typing¶
Cuando un cliente WA empieza a escribir en una conversación abierta en el downstream:
docker logs qrsgen 2>&1 | grep "typing sync"
# → time=... msg="typing sync" instance=... jid=...@s.whatsapp.net
# conv_id=42 state=composing emitted=true
En Chatwoot, el indicador "está escribiendo..." debería aparecer debajo del input de la conversación.
Read receipts¶
Cuando el cliente abre el chat y lee los mensajes del agente:
docker logs qrsgen 2>&1 | grep "receipt sync"
# → time=... msg="receipt sync" instance=... jid=...@s.whatsapp.net
# conv_id=42 kind=read ts=1716889200
En Chatwoot, los mensajes outgoing previos del agente deberían marcarse como leídos (el icono cambia a doble check azul o equivalente según tema).
Si no aparece nada:
- ¿Master switch off?
QRSGEN_TYPING_SYNC=falseoQRSGEN_READ_RECEIPTS_SYNC=false. - ¿El contacto existe en downstream? Ambas features requieren contact
- conv pre-existentes — no se crean por estas señales.
- ¿Privacy settings del sender? (solo aplica a read receipts) — si tiene confirmaciones de lectura desactivadas, no llegan eventos.
Glosario¶
*events.ChatPresence: evento de whatsmeow que indica si un
cliente está escribiendo (composing) o ha dejado de escribir
(paused) en una conversación concreta. Diferente de *events.Presence
(presencia general "online/offline" del JID).
*events.Receipt: evento de whatsmeow que llega cuando Meta
confirma un cambio de estado de un mensaje (entregado, leído,
reproducido). Su campo Type distingue los subtipos.
composing / paused: los dos estados que ChatPresence puede
reportar. composing mientras el usuario tipea; paused cuando
detiene la escritura sin enviar (o pasa un timeout interno de WhatsApp).
read / read-self: subtipos de Receipt que qrsgen propaga. El
primero es el cliente remoto leyendo los mensajes del agente; el
segundo es el propio bot owner leyendo desde otro Multi-Device. Ambos
indican que la conversación fue vista.
delivered / played / sender: subtipos de Receipt que qrsgen
no propaga. Son menos accionables para el agente (entrega ≠
lectura; audio reproducido no necesariamente leído; confirmación de
envío redundante).
typingTracker: estructura in-memory (per-conversación) que
recuerda el último estado typing reportado y cuándo. Implementa la
política "cambio de estado siempre emite; mismo estado dentro del
intervalo throttled".
minInterval: parámetro del typingTracker que define el throttle
para estados repetidos. Default 4s. Cubre el caso "el cliente WA
spammea composing cada keystroke".
SetTypingStatus: método de downstream.Client que hace
POST /toggle_typing_status con typing_status: on|off. Es la API
estándar de Chatwoot para mostrar el indicador "está escribiendo".
UpdateContactLastSeen: método de downstream.Client que hace
POST /update_last_seen con agent_last_seen_at y
contact_last_seen_at. Es la API estándar de Chatwoot para marcar la
conversación como vista por el contacto.
contact_last_seen_at: campo de la conversación en el modelo de
Chatwoot que registra cuándo el contacto vio la conversación por
última vez. La UI lo usa para renderizar el doble check azul en los
mensajes outgoing previos al timestamp.
Throttle: política que limita la frecuencia de POSTs al downstream
para evitar sobrecargarlo con eventos redundantes. En typing el
throttle es per-conversación con minInterval=4s.
Read-only sobre WhatsApp: convención de qrsgen donde casi nunca
escribe estado a WhatsApp (ni presencia, ni edición de perfil). Solo
lee. Única excepción desde v0.39.0: MarkRead se envía de vuelta a WA
cuando el agente lee en el downstream, para cerrar el ciclo del doble
check azul. Typing y edición de perfil siguen siendo read-only.
waidTracker (v0.39.0): estructura in-memory per-conversación que
guarda los WAIDs de mensajes incoming junto con su timestamp. Métodos:
RecordIncoming(convID, waid, ts) que el sync() de incoming llama
tras un PostMessage exitoso, y DrainBefore(convID, cutoffTS) que
Outgoing llama al recibir un conversation_updated. Cap por defecto
de 50 entradas/conv (FIFO al desbordar). Reset on restart.
ReadMarker (v0.39.0): interfaz minimal con un solo método
(MarkRead(ctx, chat, sender, messageIDs, ts)) que desacopla
bridge.Outgoing del cliente WA concreto. Implementada por
wameow.Conn.MarkRead en producción y por mocks en tests.
conversation_updated: webhook que Chatwoot emite cuando algo
cambia en una conversación (lectura, asignación, etiquetas, status).
Desde v0.39.0 qrsgen suscribe este evento para detectar el
agent_last_seen_at y disparar el MarkRead outgoing. Requiere
suscripción explícita en la configuración del webhook de Chatwoot.
agent_last_seen_at: campo de la conversación de Chatwoot que
registra cuándo el agente la vio por última vez. qrsgen lo lee del
webhook conversation_updated y lo usa como cutoff para drenar el
waidTracker y disparar MarkRead sobre los mensajes leídos.
Privacy settings (WhatsApp): ajustes del cliente remoto que pueden
ocultar receipts. Si "Confirmaciones de lectura" está desactivado, Meta
no emite read para sus mensajes. La cobertura de read-receipts-sync
es parcial por esta razón.