Flujo INCOMING (cliente WhatsApp → tu sistema)¶
Cliente WhatsApp envía msg/typing/read al número conectado
│
▼
Meta enruta al WebSocket activo del JID destino
│
▼
WebSocket que qrsgen mantiene abierto desde bootstrap
│ whatsmeow emite events.Message
│ events.ChatPresence ──► handleChatPresence
│ events.Receipt ──► handleReceipt
▼
wameow.handle() dispatcher (case por tipo de evento)
│
▼ events.Message
bridge/incoming.go:
│ resuelve LID↔PN si aplica (Multi-Device)
│ si msg.GetReactionMessage() != nil ──► handleReaction
│ │
│ ▼
│ resuelve sender + conv
│ aplica name resolver (LID/PN)
│ saved = IsContactSaved(jid) (v0.39.5)
│ tilde = "~" si !saved, "" si saved
│ body (v0.39.7, unificado 1:1 + grupo):
│ "`+<E164> · <tilde><name> reaccionó con <emoji>`"
│ o, si text=="" (retracted):
│ "`+<E164> · <tilde><name> quitó su reacción`"
│ (toda la línea en inline code block:
│ header + sufijo dentro del mismo par
│ de backticks. El italic markdown del
│ retracted se pierde porque Chatwoot
│ no procesa markdown dentro de inline
│ code. Alinea con el formato v0.39.6
│ del prefijo de grupo)
│ POST incoming con
│ source_id="WAID:reaction:<ID>"
│ │
│ ▼
│ (fin)
│ si fromMe=true: dedup.ShouldDrop() para evitar twin
│ body = extractTextContent(msg)
│ ├─ Conversation / ExtendedTextMessage ──► texto plano
│ ├─ LocationMessage ──► formatLocationContent (v0.36.0)
│ │ "📍 Ubicación compartida\n**<POI>**\n
│ │ <dir>\nhttps://maps.google.com/?q=<lat>,<lng>"
│ ├─ PollCreationMessage(V3) ──► formatPollContent (v0.37.0)
│ │ "🗳️ **Encuesta:** <q>\n1. ...\n
│ │ _(elige N opción/es)_"
│ └─ (nada) ──► "" → mensaje descartado si tampoco hay media
│ media = extractMedia(msg)
│ ├─ AudioMessage + PTT ──► filename="voice-note.ogg" (v0.38.0)
│ ├─ AudioMessage ──► sanitizeMime + default audio/ogg
│ └─ StickerMessage ──► default image/webp si mime vacío
│ si grupo: applyGroupSenderPrefix(body, msg, resolver)
│ saved = resolver.IsContactSaved(jid) (v0.39.5)
│ ├─ si LID y !saved: PNForLID → re-check IsContactSaved con PN
│ tilde = "" si saved, "~" si !saved (v0.39.5)
│ prefix = "`+<E164> · <tilde><name>`" (v0.39.6)
│ (v0.39.4: header completo envuelto en inline code block
│ con backticks; teléfono SIEMPRE presente.
│ v0.39.5: el tilde se prepende SOLO si el contacto no está
│ guardado — replica la convención de la UI de WhatsApp.
│ v0.39.6: el orden pasa a phone-first, separador middle
│ dot " · " (U+00B7), nombre al final. Se eliminan los **
│ de bold y los tabs porque Chatwoot no procesa bold dentro
│ de inline code y colapsa los tabs a un único espacio —
│ la heurística runes/tabs de v0.39.3 deja de aplicar)
│ construye payload {content, attachments, source_id: WAID:..., ...}
│ POST al endpoint downstream
▼
Tu sistema recibe el POST y procesa
│
└─ usage.IncIn(instance)
└─ metric qrsgen_messages_total{direction="in"}++
└─ waidTracker.RecordIncoming(convID, WAID, ts) (v0.39.0)
──► luego drenado por conversation_updated
para disparar MarkRead → WA (doble check azul)
└─ maybeAvatarSync(jid) ──► goroutine (fire-and-forget)
│
▼
GetProfilePictureID (cheap)
¿LastID != currentID? ──► no: skip
│ sí
▼
GetProfilePicture (descarga)
UploadContactAvatar(downstream)
tracker.UpdateID
events.ChatPresence (composing/paused) — v0.34.0
│
▼
manager.SetChatPresenceHandler → bridge.Incoming.HandleChatPresence
│ FindContact + FindConversation (no crea)
│ typingTracker.ShouldEmit(convID, state)
│ ├─ cambio de estado ──► true (emite)
│ ├─ mismo estado, < minInterval ──► false (throttle)
│ └─ mismo estado, ≥ minInterval ──► true
│ si true: Client.SetTypingStatus(convID, typing)
│ POST /conversations/Y/toggle_typing_status
▼
(fin)
events.Receipt (kind=read | read-self) — v0.34.1
│
▼
manager.SetReceiptHandler → bridge.Incoming.HandleReceipt
│ filtra Type in ("read","read-self"); resto: return
│ FindContact + FindConversation (no crea)
│ Client.UpdateContactLastSeen(convID, ts)
│ POST /conversations/Y/update_last_seen
│ body {agent_last_seen_at, contact_last_seen_at}=ts
▼
(fin)
LID/PN twin dedup¶
WhatsApp Multi-Device puede entregar el mismo mensaje desde un cliente
tanto via su JID PN (número) como su LID (identificador anónimo).
qrsgen detecta y descarta el twin via bridge_dedup con clave
(instance, jid_user, content_hash) y ventana configurable
(DEDUP_WINDOW_MS, default 10s).
Routing al downstream¶
El POST se hace contra DOWNSTREAM_BASE_URL/api/v1/accounts/<ACCOUNT_ID>/conversations/....
El path es Channel::Api-compatible, pero el endpoint cliente HTTP es
genérico — puedes apuntar DOWNSTREAM_BASE_URL a un proxy/webhook que
reformatee a otro shape si tu downstream no usa Channel::Api.
inbox_id se obtiene de bridge_instance.inbox_id para esa instancia;
si está NULL o 0, cae al DOWNSTREAM_INBOX_ID global.
Prefijo de grupo (v0.39.6: code block + phone-first + middle dot + tilde solo si no saved)¶
Para mensajes cuyo chat.Server == g.us (grupos), antes del POST al
downstream qrsgen llama applyGroupSenderPrefix(body, msg, resolver).
Desde v0.39.6 la función construye el header como
`+<E164> · <tilde><name>`: teléfono primero, middle dot ·
(U+00B7) con espacios a ambos lados como separador, y nombre al final.
Mantiene la lógica de IsContactSaved para el tilde introducida en
v0.39.5 — solo cambia el ensamblado del string.
saved = resolver.IsContactSaved(jid)
if jid es LID y !saved:
pn = resolver.PNForLID(jid)
if pn != "": saved = resolver.IsContactSaved(pn) ← LID/PN fallback
tilde = "~" if !saved else ""
prefix = "`" + "+" + e164 + " · " + tilde + name + "`"
- Name + phone, saved (sin tilde):
`+<E164> · <name>`\n<body> - Name + phone, no saved (con tilde):
`+<E164> · ~<name>`\n<body>
Toda la línea de header va dentro de un par de backticks (inline
code block, v0.39.4). El separador es middle dot · (U+00B7)
con un espacio a cada lado. Chatwoot lo preserva literal dentro del
code block, a diferencia de los tabs \t (que colapsa a un único
espacio) y de los **bold** (que no procesa dentro de inline code y
quedan como asteriscos literales). El orden phone-first aprovecha
la longitud predecible del E.164 para dar columna estable; el
nombre, de largo variable, ocupa la cola y no descuadra nada.
- Solo name (sin phone):
**<tilde><name>**:\n<body>— degenerado, sin code block. - Solo phone (sin name):
+<E164>:\n<body>— degenerado, sin backticks (desde v0.39.2).
Si el sender llega como LID, qrsgen sigue resolviendo a PN vía
PNForLID para obtener ContactName y phone presentables, y desde
v0.39.5 además re-chequea IsContactSaved con el PN resuelto para
acertar el bit del tilde para contactos guardados que mandaron por su
LID anonimizado.
Evolución del check IsContactSaved en este path:
- v0.32.0–v0.39.3: condicionaba si se omitía el teléfono (saved → sin phone).
- v0.39.4: no se consultaba; el prefijo era idéntico para saved y
unsaved, siempre con
~y siempre con phone. - v0.39.5: vuelve a consultarse, pero condiciona solo el tilde
~del nombre. El teléfono se sigue incluyendo siempre. - v0.39.6: misma semántica que v0.39.5; solo cambia el orden y
los separadores del header (
+phone · <~?>name) y desaparecen los**y los tabs.
Detalles e histórico en Formato del prefijo de grupo.
Reacciones (handleReaction)¶
Desde v0.33.0, bridge.Incoming.Handle chequea si el payload entrante
es una reacción antes del path normal de texto/media:
Antes de v0.33.0 estos eventos caían en el path "sin texto ni media" y
se descartaban. handleReaction:
- Resuelve sender + conversación por el mismo camino que un mensaje normal (LID↔PN, contact lookup en downstream).
- Aplica el name resolver compartido con
applyGroupSenderPrefix. Desde v0.39.4 el teléfono se incluye siempre en grupos; desde v0.39.5 el tilde~se prepende al nombre solo si el contacto no está guardado (víaIsContactSaved). - Desde v0.39.7 construye el body con un único formato unificado
para 1:1 y grupo, alineado con el prefijo de grupo v0.39.6 (siendo
<tilde>="~"si !saved,""si saved): - Reacción nueva (
text != ""):`+<E164> · <tilde><name> reaccionó con <emoji>` - Retracted (
text == ""):`+<E164> · <tilde><name> quitó su reacción`
Toda la línea (header + sufijo) va dentro del mismo par de
backticks. El italic markdown del retracted (_..._) se pierde
porque Chatwoot no procesa markdown dentro de inline code: el
sufijo queda como texto literal del code block.
- POSTea con message_type: "incoming" y
source_id: "WAID:reaction:<msg.Info.ID>" — namespace separado del
mensaje target para evitar colisión en el dedup del downstream.
Cambios v0.33.0..v0.39.6 → v0.39.7: (1) teléfono siempre presente
(antes solo en grupos con sender no saved); (2) tilde solo si no saved
(antes siempre); (3) wrap en inline code block (antes **bold**);
(4) phone-first (antes nombre primero); (5) quitó su reacción
pierde el italic (literal dentro del code block).
Casos de descarte: IsFromMe=true (reacción propia desde otro
device), contacto no existe en downstream (no se crea por una
reacción suelta), QRSGEN_REACTIONS_SYNC=false. Detalles en
Sincronización de reacciones.
Content extraction (location, polls, media polish)¶
Desde v0.36.0 / v0.37.0 / v0.38.0, extractTextContent y
extractMedia en internal/bridge/incoming.go cubren tipos de payload
que antes caían en el path "sin contenido" y se descartaban
silenciosamente:
LocationMessage(v0.36.0):formatLocationContent(loc)produce un body multilínea con header📍 Ubicación compartida(oUbicación en vivosiIsLive=true), nombre del POI en bold, dirección, linkhttps://maps.google.com/?q=<lat>,<lng>con%.6fde precisión, y comentario del sender en italic. Coords0,0descartan (devuelve""). Live locations: cada snapshot llega como mensaje independiente; no hay agregación.PollCreationMessage/PollCreationMessageV3(v0.37.0):formatPollContent(poll)produce🗳️ **Encuesta:** <pregunta>, opciones numeradas 1-based, y hint_(elige 1 opción)_/_(elige hasta N opciones)_segúnSelectableOptionsCount(sin hint paramax=0).PollUpdateMessage(votos) no se procesa — Chatwoot no tiene UI de polls que pueda reflejarlos.- Media polish (v0.38.0): voice notes (PTT=true) usan filename
voice-note.oggen lugar deaudio.opus;sanitizeMimequita el parámetro de codec delContent-Type(audio/ogg; codecs=opus→audio/ogg); stickers con mime vacío reciben defaultimage/webp. Sin transcodificación — los bytes son los mismos que llegan por WA. Solo cambia elContent-Typey filename anunciados al downstream para maximizar compatibilidad con reproductores HTML5.
Las tres versiones no introducen env vars nuevas — aplican siempre que el payload llegue por el WebSocket. Detalles en Soporte de contenido de mensajes.
Typing (handleChatPresence)¶
Desde v0.34.0, wameow.Conn expone SetChatPresenceHandler y el
dispatcher tiene un case nuevo para *events.ChatPresence. El handler
se propaga vía manager.SetChatPresenceHandler a todas las Conn
(actuales + futuras), mismo patrón que SetPictureHandler del avatar
sync.
bridge.Incoming.HandleChatPresence:
- Resuelve contacto + conversación con
FindContact/FindConversation. No crea — si no existen, descarta el evento (el primer mensaje "real" abrirá la ruta). - Consulta
typingTracker.ShouldEmit(convID, state)para decidir si POSTea. La política: cambios de estado siempre emiten; mismo estado dentro deminInterval(default 4s) se throttlea. - Si debe emitir, 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"}.
Master switch QRSGEN_TYPING_SYNC (default true). El tracker es
in-memory; restart pierde el throttle state y en el worst case se hace
una llamada HTTP extra por conversación activa. Detalles en
Typing indicators y read receipts.
Read receipts (handleReceipt)¶
Desde v0.34.1, wameow.Conn expone SetReceiptHandler y el dispatcher
tiene un case nuevo para *events.Receipt. Propagación idéntica al
patrón de typing.
bridge.Incoming.HandleReceipt:
- Filtra por
Type: soloreadyread-selfcontinúan; resto (delivered,played,sender) se ignoran porque son menos accionables para el agente. - Resuelve contacto + conversación. Si no existen, descarta.
- Llama
Client.UpdateContactLastSeen(convID, ts)que hacePOST /api/v1/accounts/X/conversations/Y/update_last_seencon body{"agent_last_seen_at": <ts>, "contact_last_seen_at": <ts>}. Ambos campos llevan el mismo valor: elreceipt.Timestamp(Unix epoch segundos) del momento en que WhatsApp registró el receipt — no cuando qrsgen lo recibió.
El resultado en el downstream: la conversación se marca como vista por
el contacto en ts, y los mensajes outgoing previos del agente se
renderizan como leídos (equivalente al doble check azul en Chatwoot).
Master switch QRSGEN_READ_RECEIPTS_SYNC (default true). Sin retry:
el siguiente read corregirá el last_seen_at si el POST falla.
Detalles en
Typing indicators y read receipts.
Mark-as-read outgoing (v0.39.0) — cierra el ciclo bidireccional¶
Desde v0.39.0 el flujo incoming registra cada mensaje en un tracker
in-memory tras PostMessage exitoso para permitir que el path
outgoing (webhook conversation_updated desde Chatwoot) propague
MarkRead a WhatsApp cuando el agente lee. Es el inverso del
v0.34.1 (read receipts incoming).
sync() incoming, tras PostMessage exitoso:
│
└─ waidTracker.RecordIncoming(convID, msg.Info.ID, msg.Info.Timestamp)
cap 50 entradas/conv (FIFO al desbordar)
────────────────────────────────────────────────────────────────────
POST /api/instances/{name}/webhook (Chatwoot envía conversation_updated)
│
▼
bridge.Outgoing.HandleWebhook:
│ event == "conversation_updated"?
│ payload.Conversation.AgentLastSeenAt > 0?
│ (si no, ignorar — Chatwoot emite el evento por muchos
│ motivos: cambio de etiqueta, asignación, status...)
▼
waids := waidTracker.DrainBefore(convID, agent_last_seen_at)
│ devuelve los WAIDs con ts ≤ cutoff (idempotente: la siguiente
│ llamada con mismo cutoff devuelve [])
▼
ReadMarker.MarkRead(ctx, chat, sender, waids, agent_last_seen_at)
│ wameow.Conn.MarkRead → client.MarkRead() de whatsmeow
▼
Cliente WA ve doble check azul sobre los mensajes incluidos
Componentes nuevos (v0.39.0):
wameow.Conn.MarkRead(ctx, chat, sender, messageIDs, ts)— wrapper fino sobreclient.MarkRead()de whatsmeow. Idempotente.bridge.waidTracker— tracker in-memory per-conv. MétodosRecordIncoming(llamado por incomingsync()) yDrainBefore(llamado por outgoingHandleWebhook).bridge.ReadMarker— interfaz pequeña con un solo método; desacoplaOutgoingdel cliente WA real.bridge.Outgoing.EnableMarkAsRead(waids, marker)— wire enmain.go.bridge.Incoming.EnableMarkAsRead()devuelve*waidTracker; el mismo tracker se inyecta enOutgoing.EnableMarkAsReadpara que ambas direcciones compartan estado.WebhookPayload.Conversation.AgentLastSeenAt/ContactLastSeenAt— campos nuevos parseados del webhook.senderAdapter.MarkReadenmain.gopuentea haciawameow.Conn.
Configuración REQUERIDA en Chatwoot: el webhook (mismo endpoint
que ya se usa para message_created) debe suscribir adicionalmente
conversation_updated. Sin esa config el feature no rompe, solo no
opera (qrsgen nunca recibe la señal de cuándo el agente leyó).
Master switch QRSGEN_MARK_AS_READ_OUTGOING (default true). Sin
retry: tracker in-memory + idempotencia natural cubren el caso
fallido. Restart pierde el historial — mensajes leídos durante
downtime no se marcan retroactivamente en WA (cosmético). Detalles en
Mark-as-read outgoing (v0.39.0).
Side effect: avatar sync¶
Tras el POST al downstream, sync() llama también a maybeAvatarSync
(desde v0.31.1). Esta función consulta el tracker in-memory
(avatar_tracker.go); si el TTL ha vencido para (instance, jid),
spawnea una goroutine que sincroniza el avatar de WhatsApp al
downstream. Es fire-and-forget — errores se loguean como warn pero
nunca bloquean el flujo del mensaje. Detalle completo en
Avatar sync.
Adicionalmente, qrsgen subscribe events.Picture de whatsmeow (desde
v0.31.2). Cuando el usuario cambia su foto, dispara
HandlePictureChange que resetea el tracker y fuerza un sync
inmediato sin esperar al siguiente mensaje.
Glosario¶
Incoming: mensaje que viene del cliente WhatsApp hacia tu sistema (opuesto a "outgoing", que va del sistema al cliente).
events.Message: evento emitido por la librería whatsmeow cuando llega un mensaje por el WebSocket. Contiene el contenido, sender, timestamp, etc.
fromMe: campo en events.Message que indica si el mensaje fue
enviado por el propio número conectado (típicamente desde otro device
Multi-Device del usuario). qrsgen lo detecta para evitar dobles
entregas.
Idempotencia incoming: técnica donde qrsgen detecta y descarta
mensajes duplicados via bridge_dedup. Clave compuesta por
(instance, jid_user, content_hash).
Channel::Api-compatible: formato JSON estándar que muchos sistemas de ticketing usan para mensajes (originalmente Chatwoot). qrsgen genera este formato por defecto en sus POSTs al downstream.
WAID (WhatsApp ID): identificador único que WhatsApp asigna a cada
mensaje. qrsgen lo guarda en source_id del mensaje sincronizado al
downstream con prefijo WAID: para evitar reprocesarlo como outgoing.
Inbox ID: identificador numérico de "buzón" en el sistema downstream. qrsgen lo propaga al POSTear incoming para que el downstream sepa en qué conversación encolar el mensaje.
Routing al downstream: qrsgen no decide qué hacer con cada mensaje; solo lo entrega al downstream que el integrador haya configurado (Chatwoot, n8n proxy, app custom).
Avatar sync (side-effect): spawn fire-and-forget que descarga la
foto de perfil del JID en WhatsApp y la sube al downstream como
avatar del contacto. Corre en paralelo al POST del mensaje. Gated
por un tracker in-memory que usa TTL + comparación de info.ID para
minimizar tráfico.
Prefijo de grupo: header que applyGroupSenderPrefix antepone
al body para mensajes de grupo. Desde v0.32.0 hasta v0.39.3 fue
adaptativo (omitía el teléfono para senders guardados, vía
IsContactSaved). En v0.39.4 se unificó: header en inline code
block con backticks y teléfono siempre presente, sin consultar
IsContactSaved. Desde v0.39.5 applyGroupSenderPrefix vuelve a
consultar IsContactSaved pero solo para decidir si prepende el
tilde ~ al nombre: contactos saved (FullName/FirstName) van sin
tilde, contactos solo PushName van con ~. El teléfono sigue
incluyéndose siempre (no se revierte el cambio de v0.39.4). Desde
v0.39.6 el header se reordena a `+<E164> · <~?>name`
(phone-first, middle dot · (U+00B7) como separador) y se eliminan
los marcadores **bold** y los tabs \t porque Chatwoot no procesa
bold dentro de inline code y colapsa los tabs a un único espacio
dentro del code block — la heurística de tab count variable de
v0.39.3 deja de tener efecto y se retira del path.
handleReaction: handler en bridge.Incoming (desde v0.33.0) que
intercepta ReactionMessage antes del path normal de texto/media y
los propaga al downstream como mensaje incoming con
source_id: "WAID:reaction:<ID>".
WAID:reaction:<ID> namespace: prefijo para el source_id de
reacciones. Garantiza unicidad respecto al mensaje target (que usa
WAID:<ID>) y evita que el dedup del downstream las confunda como
duplicados.
handleChatPresence: handler en bridge.Incoming (desde v0.34.0)
que procesa *events.ChatPresence (composing/paused) y los propaga
al downstream vía Client.SetTypingStatus. Throttled por el
typingTracker.
handleReceipt: handler en bridge.Incoming (desde v0.34.1) que
procesa *events.Receipt filtrando por Type in ("read","read-self")
y los propaga al downstream vía Client.UpdateContactLastSeen.
typingTracker: estructura in-memory per-conversación que decide
si un evento ChatPresence debe emitir o silenciarse. Cambios de
estado siempre emiten; mismo estado dentro de minInterval (default
4s) NO emite. Reset on restart.
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. qrsgen lo actualiza con el receipt.Timestamp de los
read receipts entrantes.
agent_last_seen_at: campo de la conversación en el modelo de
Chatwoot que registra cuándo el agente vio la conversación por última
vez. Llega en el webhook conversation_updated y desde v0.39.0 qrsgen
lo usa como cutoff para drenar el waidTracker y disparar MarkRead
hacia WhatsApp.
waidTracker (v0.39.0): tracker in-memory per-conversación que
guarda los WAIDs de mensajes incoming junto con su timestamp para
permitir MarkRead outgoing cuando el agente lee. Métodos
RecordIncoming(convID, waid, ts) (llamado en sync() incoming tras
PostMessage) y DrainBefore(convID, cutoffTS) (llamado en
HandleWebhook outgoing al recibir conversation_updated). Cap por
defecto de 50 entradas/conv (FIFO). Reset on restart.
ReadMarker (v0.39.0): interfaz minimal (un solo método
MarkRead(ctx, chat, sender, messageIDs, ts)) que desacopla
bridge.Outgoing del cliente WA concreto. Implementada por
wameow.Conn.MarkRead.
conversation_updated: webhook emitido por Chatwoot cuando algo
cambia en una conversación. Desde v0.39.0 qrsgen lo procesa para
detectar agent_last_seen_at y disparar MarkRead outgoing. Requiere
suscripción explícita en la configuración del webhook (no viene por
defecto).