Características destacadas¶
Multi-instancia real¶
Un binario gestiona N números independientes, cada uno con su WebSocket contra Meta y su estado aislado. Sin proceso separado por instancia.
Outbox persistido¶
Cola en Postgres con TTL de 5 minutos. Cuando una instancia está disconnected y llega un outgoing, qrsgen lo encola y lo entrega al volver. Cero pérdida en restarts cortos.
BanWatcher proactivo¶
Tres señales con thresholds configurables:
- Velocity: mensajes por minuto.
- Diversity: JIDs únicos contactados por ventana.
- Delivery ratio: éxitos / intentos.
Score 0-1 + level cualitativo. Emite el evento ban_risk cuando cruza
umbrales, para que tu sistema reduzca ritmo antes de que WhatsApp
sancione.
Audit log inmutable¶
Tabla bridge_audit_log con triggers PL/pgSQL que rechazan UPDATE y
DELETE. Cualquier operación queda registrada con timestamp y metadata
JSONB. Tamper-evident a nivel DB.
Usage tracking + facturación¶
Counters diarios persistidos en bridge_usage_daily. Endpoint
/api/usage/summary agrega por (owner_tag, mes) listo para billing
multi-tenant ligero. qrsgen no decide pricing — solo expone los hechos.
Sincronización de avatares WhatsApp¶
qrsgen descarga la foto de perfil de cada contacto/grupo WhatsApp y la sube al downstream como avatar. Tres capas de sincronización:
- Al crear contacto (v0.31.0) — primera foto disponible.
- Refresh con TTL (v0.31.1) — tracker en memoria que compara
info.ID(cheap metadata, no descarga) cadaQRSGEN_AVATAR_REFRESH_TTLy solo descarga si cambió. - Tiempo real (v0.31.2) — subscribe a
events.Picturede whatsmeow: cuando el usuario cambia su foto en WhatsApp móvil, el avatar downstream se actualiza en ~1s sin esperar al siguiente mensaje.
Más un endpoint POST /api/instances/:name/avatars/resync (v0.31.3) para
backfill de contactos viejos (creados antes de v0.31.x o inactivos).
Detalles completos en Avatar sync.
Formato del prefijo de grupo¶
Desde v0.39.6, el prefijo que qrsgen antepone al body de mensajes
de grupo tiene la estructura `<phone> · <~?><name>`: teléfono
primero (E.164), separador middle dot · (U+00B7) con espacios a
ambos lados, y nombre al final. Todo envuelto en un inline code
block (backticks). El teléfono se incluye siempre. El único bit
que cambia según el contact store es el tilde ~ delante del
nombre: aparece solo para senders no guardados en la libreta del
bot owner. Replica la convención de la propia UI de WhatsApp.
- Saved (sin tilde):
- No saved (con tilde):
- Saved, nombre largo:
Chatwoot renderiza el header como monoespaciado con fondo sutil y
preserva el · y el ~ literales dentro del code block, dando
contraste visual con el body. La longitud predecible del E.164 al
inicio de cada header da columna estable; el nombre, de largo
variable, ocupa la cola.
Cambio respecto a v0.39.5: en v0.39.2–v0.39.5 el header era
`**<~?>name**<tabs>+phone` (bold + tabs + phone al final).
v0.39.6 lo reordena a `+phone · <~?>name` y elimina los ** y
los tabs porque, en observación directa del render de Chatwoot:
(1) Chatwoot no procesa **bold** dentro de inline code (los
asteriscos quedaban literales), y (2) Chatwoot colapsa los tabs \t
a un único espacio dentro del code block (la heurística de 1/2 tabs
de v0.39.3 no producía alineación real). El bit del tilde (lógica de
v0.39.5: ~ solo si IsContactSaved == false) se preserva intacto —
solo cambia su posición en el header. Sin env vars. Detalles e
histórico en
Formato del prefijo de grupo.
Sincronización de reacciones WhatsApp¶
Desde v0.33.0, cuando un usuario reacciona a un mensaje en WhatsApp
(long-press → emoji), qrsgen propaga la reacción al downstream como
un nuevo mensaje incoming. Antes de esta versión los eventos
ReactionMessage se descartaban silenciosamente.
Desde v0.39.7 el formato del body se realinea con el del prefijo
de grupo v0.39.6: una sola línea envuelta en inline code block,
phone-first, middle dot · como separador, y tilde ~ delante del
nombre solo si el contacto no está guardado en la libreta del bot
owner. El mismo formato aplica tanto en chats 1:1 como en grupos
(antes 1:1 y grupos tenían dos formatos distintos).
- Reacción de contacto saved (en agenda):
- Reacción de contacto no saved (PushName):
- Reacción retirada (text=""):
Toda la línea va dentro del mismo par de backticks (header + sufijo).
Eso difiere del prefijo de grupo, donde el code block envuelve solo
el header y el body del mensaje viene fuera — la reacción no tiene
body propio, el "contenido" es el sufijo reaccionó con <emoji> o
quitó su reacción. El italic markdown del retracted desaparece
(_quitó su reacción_ → quitó su reacción literal) porque
Chatwoot no procesa markdown dentro de inline code.
Mismo path platform-agnostic (downstream.Router.PostMessage) y mismo
resolver de nombre que el prefijo de grupo. Master switch via
QRSGEN_REACTIONS_SYNC (default true). Las reacciones del propio
bot owner (IsFromMe=true) se ignoran. Detalles e histórico de
versiones en
Sincronización de reacciones.
Typing indicators, read receipts y mark-as-read bidireccional¶
Desde v0.34.0, v0.34.1 y v0.39.0, qrsgen propaga señales de interacción en tiempo real en ambas direcciones:
- Typing indicators (v0.34.0) — cuando el cliente WhatsApp empieza
a escribir (
composing) o se detiene (paused), qrsgen llamaPOST /toggle_typing_statusy el agente ve "está escribiendo..." en el panel del downstream. Throttle in-memory per-conversación: cambios de estado siempre emiten, mismo estado dentro de 4s se silencia para no inundar al downstream con keystrokes repetidos. Master switch viaQRSGEN_TYPING_SYNC(defaulttrue). - Read receipts incoming (v0.34.1) — cuando el cliente abre el chat
y lee los mensajes del agente, qrsgen llama
POST /update_last_seenconagent_last_seen_atycontact_last_seen_atambos igual al timestamp del receipt. La UI marca los mensajes del agente como leídos (equivalente al doble tick azul). Solo se propaganreadyread-self;delivered,played,senderse ignoran por menos accionables. Master switch viaQRSGEN_READ_RECEIPTS_SYNC(defaulttrue). - Mark-as-read outgoing (v0.39.0) — cierra el ciclo bidireccional:
cuando el agente abre la conversación en Chatwoot y la marca como
leída, qrsgen propaga
MarkReada WhatsApp para que el cliente remoto vea el doble check azul sobre sus mensajes incoming. qrsgen registra cada WAID en un tracker in-memory (waidTracker) tras unPostMessageexitoso, y al recibir el webhookconversation_updateddrena los WAIDs≤ agent_last_seen_aty llamaclient.MarkRead()de whatsmeow. Requiere suscribir el eventoconversation_updateden el webhook de Chatwoot (sin esa config el feature no rompe, solo no marca). Master switch viaQRSGEN_MARK_AS_READ_OUTGOING(defaulttrue).
Las tres son fire-and-forget. Desde v0.39.0 el MarkRead outgoing es la
única excepción a la convención "read-only sobre WhatsApp"; typing y
edición de perfil siguen siendo read-only. Limitaciones: grupos solo
muestran un indicador agregado ("alguien está escribiendo..."); privacy
settings del sender pueden ocultar receipts (cobertura parcial); el
waidTracker es in-memory y se pierde en restart, así que mensajes
leídos durante downtime no se marcan retroactivamente en WA. Detalles
en Presencia y read receipts.
Soporte de contenido de mensajes (location, polls, media polish)¶
Desde v0.36.0, v0.37.0 y v0.38.0, qrsgen extiende
extractTextContent / extractMedia para serializar tipos de payload
WhatsApp que antes caían en el path "sin contenido" y se descartaban.
- Location messages (v0.36.0) — cuando el cliente comparte una
ubicación, qrsgen renderiza un body multilínea con header
📍 Ubicación compartida(oUbicación en vivosiIsLive), nombre del POI en bold si WA lo provee, dirección, link a Google Maps con%.6fde precisión y comentario del sender en italic. Coordenadas0,0se descartan como inválidas. Live locations llegan como múltiples snapshots independientes — qrsgen no agrega. - Polls (v0.37.0) —
PollCreationMessage(v1) yPollCreationMessageV3se renderizan como🗳️ **Encuesta:** <pregunta> - lista numerada de opciones + hint
_(elige 1 opción)_/_(elige hasta N opciones)_segúnSelectableOptionsCount. Sin hint paramax=0(unlimited). Los votos posteriores (PollUpdateMessage) NO se propagan: Chatwoot no tiene widget nativo de polls que pueda reflejarlos correctamente. - Media polish (v0.38.0) — sin transcodificar contenido, mejora la
compatibilidad con reproductores HTML5: voice notes (PTT=true) usan
filename
voice-note.oggen lugar deaudio.opus, el mime se sanea consanitizeMime(audio/ogg; codecs=opus→audio/ogg), y los stickers con mime vacío reciben defaultimage/webp. No incluye conversión WebP→PNG ni Opus→AAC (requiere ffmpeg en container, fuera de scope).
Las tres comparten propiedad: no hay env var nueva. Aplican siempre que el payload llegue por el WebSocket. Detalles completos en Soporte de contenido de mensajes.
Retroactive name update (v0.40-v0.43)¶
Cuando recibes un mensaje de un contacto que no está guardado en
la agenda de WhatsApp del bot owner, qrsgen renderiza el header con
tilde (~Richard) y nombre PushName. Si más tarde añades ese
contacto a tu agenda (típicamente desde el móvil), qrsgen detecta el
cambio vía *events.Contact y reescribe retroactivamente:
- Headers de mensajes históricos (v0.40.0) — PATCH del
contentde cada msg posteado al downstream con el nombre canónico y sin tilde. Estado tracked enbridge_msg_history(v0.41.0 persistencia Postgres) — sobrevive a restarts. - Nombre del contact en Chatwoot (v0.43.0) — PUT
/contacts/{id}con el name canónico. Aplica también en 1:1 chats donde no hay prefix de grupo pero el contact name sí se ve en sidebar. - Bulk reconcile endpoint (v0.43.0):
POST /api/instances/:name/retroactive/reconcileitera el contact store local de whatsmeow y dispara updates por cada saved. Útil para bootstrap inicial al adoptar la feature por primera vez. Devuelve{instance, scanned, triggered}.
Control:
- QRSGEN_RETROACTIVE_NAME_UPDATE (default true)
- QRSGEN_RETROACTIVE_PERSIST (default true) — sin esto, in-memory
only y se pierde en restart.
- QRSGEN_RETROACTIVE_TTL (default 720h) — retención en DB; cron
cleanup cada 6h.
- QRSGEN_RETROACTIVE_CAP_PER_SENDER (default 200) — cap FIFO de
mensajes recordados por sender.
Métricas:
qrsgen_realtime_events_total{feature="retroactive_name", result=...}
con results: ok, ds_error, skip_disabled, skip_fullsync,
skip_empty_name, skip_no_entries.
Quote/reply context bidireccional (v0.42 + v0.44)¶
WhatsApp permite responder a un mensaje específico (long-press → Reply). qrsgen propaga ese contexto en ambos sentidos:
Incoming: quote como blockquote en Chatwoot¶
Desde v0.42.0, cuando un usuario WhatsApp envía un reply, qrsgen
extrae el ContextInfo.QuotedMessage y lo renderiza como blockquote
markdown sobre el body. Desde v0.44.4 el formato se alinea con
el group prefix:
↪(U+21AA) sustituye al emoji ↩️ — glyph plano sin variation selector, sale uniforme en cualquier renderer.- Author resuelto vía
WAResolvercon la misma cadena que el group prefix (saved/unsaved con~, fix v0.39.9 LID→PN canónico). - Texto citado truncado a 200 runas con
…para no inflar la conv. - Soporta todos los tipos media con placeholders emoji (🖼️/🎥/🎤/📄/🟩/📍).
- En 1:1 (sin Participant) el header se omite — el author es trivialmente el otro extremo del chat.
Outgoing: reply nativo en WhatsApp¶
Desde v0.44.0, cuando el agente hace quote-reply en el composer
de Chatwoot (long-tap → Reply en un msg incoming, o el botón quote),
Chatwoot envía el webhook outgoing con
content_attributes.in_reply_to=<chatwoot_msg_id>. qrsgen:
- Resuelve
chatwoot_msg_id → WAIDvía elmsg_historytracker (que registra TODOS los incoming desde v0.44.0, no solo los con prefix de grupo). - Envía el mensaje vía whatsmeow con
ContextInfopoblado (StanzaId,Participant,QuotedMessage). - El cliente WA receptor ve el quoted preview tappable que enlaza al mensaje original.
Si el WAID no se encuentra (msg pre-feature, evicted del cap, o pre
v0.44.0 sin columna waid poblada), degrada silencioso a SendText
plano — no es un error.
Burst tracker fix (v0.44.1)¶
Antes de v0.44.1 el groupTracker (supresor de headers en bursts
del mismo sender) no se actualizaba cuando el bot enviaba — whatsmeow
no emite *events.Message para envíos del propio cliente, así que el
flow de Incoming.Handle nunca veía los msgs del bot. Resultado: tras
una respuesta del agente en un grupo, el siguiente msg del usuario
seguía dentro del burst original sin emitir header.
Fix: Outgoing.markBotInGroup llama Incoming.MarkBotSentInGroup
tras un send exitoso a un @g.us. El groupTracker registra _bot
como último sender, rompiendo el burst.
Observabilidad de features real-time¶
Desde v0.35.0, las cuatro features real-time (avatar sync,
reacciones, typing, read receipts) emiten al counter Prometheus
unificado qrsgen_realtime_events_total{feature,result,instance}.
Permite calcular en Grafana tasas de éxito (ok), cobertura
(ok vs wa_miss), efectividad del throttle (throttled / total)
y errores downstream (ds_error) por feature sin parsear logs.
Cardinalidad ~32–320 series para despliegues típicos. Catálogo
completo, queries y alerting sugerido en
Observabilidad — qrsgen_realtime_events_total.
HMAC opcional del webhook¶
WEBHOOK_HMAC_SECRET activa firma HMAC-SHA256 obligatoria en el
endpoint /webhook. Previene inyecciones desde dentro del overlay
LAN.
Read-only rootfs¶
El container corre con filesystem read-only + tmpfs en /tmp. Imagen
distroless sin shell ni package manager. Un atacante con RCE no puede
instalar herramientas, persistir implantes ni escalar a root.
Backups Postgres automatizados¶
Systemd timer diario con retención 7 días / 4 semanas. Runbook de
restore incluido. Off-site backup configurable con un ExecStartPost=.
12 lifecycle events¶
Conexión, desconexión, ban risk, outbox expirations... cada uno se emite como webhook HTTP a la URL que configures por instancia. Catálogo completo en Lifecycle webhooks.
Glosario¶
Outbox: cola persistida en Postgres donde van los mensajes outgoing cuando la instancia está temporalmente desconectada. Se reentregan al volver, con TTL de 5 minutos.
TTL (Time To Live): tiempo máximo que un mensaje puede esperar en la outbox antes de expirar.
BanWatcher: módulo interno que analiza el ritmo de envíos para detectar patrones que WhatsApp suele penalizar.
Velocity: mensajes outgoing por unidad de tiempo. Una de las tres señales del BanWatcher.
Diversity: número de JIDs únicos contactados por ventana de tiempo. Otra señal del BanWatcher.
Delivery ratio: fracción de envíos exitosos sobre intentos totales. Tercera señal del BanWatcher.
Audit log: tabla bridge_audit_log con triggers DB que rechazan
UPDATE/DELETE — registro inmutable de operaciones.
Tamper-evident: propiedad donde cualquier modificación al log es detectable o imposible. qrsgen lo garantiza a nivel DB.
Usage tracking: contadores diarios de mensajes y eventos por instancia, persistidos en Postgres para reporting/facturación.
owner_tag: string libre para mapear instancias a tenants (clientes). qrsgen lo expone en agregados de billing pero no lo interpreta.
Multi-tenant ligero: arquitectura donde un solo proceso sirve a varios clientes identificándolos solo por etiqueta.
HMAC (Hash-based Message Authentication Code): firma criptográfica que demuestra que un mensaje viene de quien dice y no ha sido modificado.
Distroless: imagen Docker mínima sin shell ni package manager. Reduce la superficie de ataque ante RCE.
Read-only rootfs: filesystem del container marcado como solo lectura. Cualquier intento de escribir falla — buena señal de compromiso si ocurre.
Lifecycle event: notificación HTTP que qrsgen POSTea cuando ocurre algo relevante en una instancia (conexión, QR, ban risk, etc.).
Avatar sync: descarga de la foto de perfil de WhatsApp y subida al downstream como avatar del contacto. Read-only sobre WhatsApp: qrsgen nunca escribe en el perfil del usuario.
Letter-avatar: avatar por defecto que algunos downstreams (ej. Chatwoot) generan con las iniciales del nombre cuando no hay imagen configurada. El avatar sync los reemplaza por las fotos reales de WA.
Prefijo de grupo: línea que qrsgen antepone al body de cada
mensaje de grupo identificando al sender (teléfono + nombre). Permite
al agente que lee la conversación saber quién escribió cada mensaje
sin abrir el subhilo del grupo. Desde v0.39.2 el separador y el
formato evolucionaron varias veces: tabs (v0.39.2), tab count
variable (v0.39.3), inline code block wrap (v0.39.4), tilde solo
para no saved (v0.39.5). Desde v0.39.6 el header se reordena a
`+phone · <~?>name` (teléfono primero, middle dot · como
separador, nombre al final) 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. El teléfono sigue incluyéndose
siempre y el tilde ~ antes del nombre aparece solo si el contacto
no está guardado en la libreta del bot owner — replica la convención
de la UI de WhatsApp.
Contacto saved (libreta): JID que el dueño del número conectado
tiene en su libreta del móvil, con FullName o FirstName propagado
hasta el contact store de whatsmeow. Afecta a qué string se muestra
como nombre (FullName/FirstName sobre PushName) y, desde v0.39.5,
también a si el nombre lleva tilde ~ delante (sin tilde si saved,
con tilde si no saved). No afecta a la presencia del teléfono — desde
v0.39.4 va siempre.
PushName: nombre que el propio sender configura en su WhatsApp.
qrsgen lo usa como fallback de display pero NO lo cuenta como
"guardado" — viene del sender, no de la decisión del bot owner.
Senders con solo PushName aparecen con ~ delante desde v0.39.5.
Tilde ~ del prefijo de grupo (v0.39.5): marca visual antes del
nombre que indica "este contacto no está en la libreta del bot
owner". Se prepende cuando IsContactSaved(jid) == false (solo
PushName disponible). Para contactos saved (FullName/FirstName) el
nombre va plano. Sigue la convención de la propia UI de WhatsApp.
Reacción (WhatsApp): emoji que un usuario añade a un mensaje
existente mediante long-press → tap en el emoji. WhatsApp lo entrega
como un ReactionMessage que apunta al msg.Info.ID del mensaje
target. qrsgen lo sincroniza al downstream como un nuevo mensaje
incoming desde v0.33.0.
ReactionMessage: tipo de payload en events.Message cuando el
sender reaccionó en vez de enviar texto/media. Tiene Text (emoji o
"" si retiró la reacción) y referencia al mensaje target.
Reacciones sync: propagación de reacciones WhatsApp al downstream.
Read-only sobre WhatsApp (qrsgen no envía reacciones de vuelta).
Controlada por QRSGEN_REACTIONS_SYNC. Desde v0.39.7 el body
adopta el mismo formato que el prefijo de grupo v0.39.6
(`+<E164> · <~?>name reaccionó con <emoji>`), con teléfono
siempre presente, tilde solo si el contacto no está guardado, y
sin italic en la variante retracted.
Typing indicator (sync): propagación de los eventos
*events.ChatPresence (composing/paused) de WhatsApp al downstream
vía POST /toggle_typing_status. Permite al agente ver "está
escribiendo..." mientras el cliente WA tipea. Throttled in-memory con
minInterval=4s por conversación. Controlado por QRSGEN_TYPING_SYNC.
Read receipt (sync): propagación de los *events.Receipt con
Type in ("read","read-self") de WhatsApp al downstream vía
POST /update_last_seen. Actualiza contact_last_seen_at con el
timestamp del receipt, marcando los mensajes outgoing previos del
agente como leídos en la UI. Controlado por
QRSGEN_READ_RECEIPTS_SYNC.
*events.ChatPresence: evento de whatsmeow que indica si un
cliente está escribiendo (composing) o ha dejado de escribir
(paused) en una conversación concreta.
*events.Receipt: evento de whatsmeow que llega cuando Meta
confirma un cambio de estado de un mensaje (entregado, leído,
reproducido). qrsgen filtra por Type y solo propaga read y
read-self.
typingTracker: estructura in-memory per-conversación que
deduplica los POSTs toggle_typing_status al downstream. Cambios de
estado siempre emiten; mismo estado dentro de minInterval (default
4s) NO emite.
Mark-as-read outgoing (v0.39.0): propagación de la lectura del
agente en Chatwoot hacia WhatsApp via client.MarkRead() de whatsmeow.
Es el inverso de read receipts incoming (v0.34.1) y cierra el ciclo
bidireccional del doble check azul. Controlado por
QRSGEN_MARK_AS_READ_OUTGOING. Requiere que el webhook de Chatwoot
suscriba el evento conversation_updated además de message_created.
waidTracker: estructura in-memory per-conversación introducida en
v0.39.0 que registra los WAIDs de mensajes incoming junto con su
timestamp, para drenarlos cuando el agente lea en el downstream.
Compartida entre bridge.Incoming (registra) y bridge.Outgoing
(drena). Cap de 50 entradas/conv (FIFO) y reset on restart.
ReadMarker: interfaz minimal de qrsgen (v0.39.0) con un solo
método MarkRead(ctx, chat, sender, messageIDs, ts). Desacopla
bridge.Outgoing del cliente WA concreto e implementada por
wameow.Conn.MarkRead.
conversation_updated: webhook que Chatwoot emite cuando algo
cambia en una conversación (lectura, asignación, etiquetas). qrsgen
suscribe este evento desde v0.39.0 para detectar el
agent_last_seen_at y disparar MarkRead outgoing.
LocationMessage: payload de WhatsApp cuando el cliente comparte
una ubicación. Trae DegreesLatitude/DegreesLongitude y
opcionalmente Name (POI), Address, Comment, IsLive. Desde
v0.36.0 qrsgen lo serializa con formatLocationContent a un body
multilínea con link a Google Maps.
Live location: ubicación compartida de forma continua durante un
periodo (15min/1h/8h). WhatsApp envía múltiples LocationMessage con
IsLive=true y coords actualizadas. qrsgen NO agrega: cada snapshot
llega como mensaje incoming independiente al downstream.
PollCreationMessage / PollCreationMessageV3: dos shapes del
payload de encuesta (v1 legacy, v3 moderno). Mismos campos relevantes:
Name (pregunta), Options[], SelectableOptionsCount. Desde v0.37.0
qrsgen renderiza ambos con formatPollContent como lista numerada.
PollUpdateMessage: payload de voto en una encuesta. NO se propaga
al downstream: Chatwoot no tiene UI de polls nativa que pueda reflejar
los votos aggregados sin contaminar el thread.
sanitizeMime: helper (v0.38.0) que quita el parámetro de codec
del Content-Type (audio/ogg; codecs=opus → audio/ogg). Maximiza
compatibilidad con reproductores HTML5.
Voice note (PTT): grabación de audio del botón micrófono de WA
(push-to-talk). Detectado vía am.GetPTT(). Desde v0.38.0 se sube con
filename voice-note.ogg para que los browsers disparen el decoder
Opus automáticamente.