@liftitapp/wa-conversations
v0.8.2
Published
Visor de conversaciones de WhatsApp del ecosistema Liftit: lista, hilo, media y composer.
Readme
@liftitapp/wa-conversations
Visor de conversaciones de WhatsApp del ecosistema Liftit — lista de conversaciones, hilo bidireccional con ticks de entrega, media y composer de texto.
Se publica en dos formas desde un solo core:
| Entry | Qué es | Para quién |
|---|---|---|
| ./react | ESM con React como peerDependency | lms-web-app (React 19), sin duplicar React |
| ./element | Bundle self-contained que registra <wa-conversations> | Cualquier host, sin depender de su versión de React |
El elemento <wa-conversations> — configurar por atributos
Un host con SOLO HTML monta el visor completo — sin escribir una línea de JS:
<script type="module" src="…/@liftitapp/wa-conversations/dist/element/index.js"></script>
<wa-conversations
phone-number-id="109876543210987"
api-url="https://…/api/v1"
token="…"
></wa-conversations>| Atributo | Qué es | Comportamiento |
|---|---|---|
| phone-number-id | El scope del inbox | Reactivo: cambiarlo remonta el inbox contra el scope nuevo (WC-03) |
| api-url | La base del servicio (http:/https:; otro esquema se rechaza) | Cambiarla en caliente remonta igual — es otro backend. El caso de uso normal es setearla una vez |
| token | El Bearer de cada request | Se lee en CADA request, nunca se cachea: rotar el atributo al renovar sesión funciona sin más, y NO remonta el visor |
| phone | Opcional — preselecciona el hilo de ese teléfono al montar | Reactivo: cambiarlo remonta el visor con la preselección nueva. Sin él, el visor arranca en la lista como siempre |
| pinned | Opcional — ancla el visor a phone: ni lista ni "Volver", a cualquier ancho | Booleano por presencia (como disabled): pinned, pinned="" y pinned="pinned" anclan; sólo pinned="false" desactiva. Reactivo. Sin phone no hace nada |
| refresh-interval | Opcional — cada cuántos milisegundos el visor se vuelve a leer solo | Por defecto 10 000. refresh-interval="0" apaga el refresco; un valor no numérico o negativo cae al default y no apaga nada. Reactivo y sin remontar: cambiarlo no cuesta la conversación abierta |
Sobre phone: el valor viaja verbatim al visor y tiene que ser la forma canónica del
servicio — E.164 CON + (p. ej. +573112620405). Con otra forma el hilo carga igual, pero el
resaltado de la fila en la lista no matchea. Normalizar el teléfono es responsabilidad del
host. En layout angosto (contenedor ≤ 520 px) con phone seteado, el visor muestra solo el
hilo, con un botón "Volver" hacia la lista.
Sobre pinned: es para el host que ya sabe de QUIÉN es la conversación y monta el visor dentro de
su propio contexto — el drawer que abre una fila de una tabla. Ahí la lista no es una comodidad:
ofrecería saltar a las conversaciones de otras personas desde una fila que habla de una sola, y el
"Volver" llevaría a una bandeja que ese drawer no promete. Con pinned, el mismo elemento a 780 px
sigue mostrando solo el hilo anclado; sin pinned, a ese ancho muestra lista e hilo lado a
lado — que es lo que quiere el host que abre el visor como bandeja.
Sobre refresh-interval: con el hilo abierto, un mensaje nuevo aparece solo — del lifter o
del bot— y los ticks avanzan de sent a delivered a read sin tocar nada; lo mismo en la
bandeja (DEV-5143). El visor vuelve a leer por el MISMO fetcher/atributos de siempre: no aparece
ninguna URL nueva. Con la pestaña en segundo plano no sale ni un request —un operador con ocho
pestañas del LMS no puede generar tráfico en las ocho— y al volver a la pestaña se lee de
inmediato, sin esperar el próximo intervalo. Al desmontar el elemento no queda ningún timer vivo.
Con configuración incompleta (atributo ausente o vacío, y sin property fetcher que los supla),
el elemento pinta un panel dentro del shadow DOM que nombra los atributos faltantes — nombres,
jamás valores — y remite a este README.
La property fetcher — precedencia sobre los atributos
Quien traduce los atributos a URLs es un default-fetcher que vive en el entry ./element — el
único lugar del paquete autorizado a mapear descriptores→URL. Un host que puede ejecutar JS
tiene una alternativa mejor: asignar la property fetcher (el.fetcher = miFetcher). Con la
property puesta, los atributos quedan ignorados y el paquete no ve ni URLs ni tokens — la
asignación funciona incluso ANTES de que cargue el bundle (lazy upgrade).
Recomendada para hosts que puedan: un token en un atributo es visible en el DOM del host
(trade-off aceptado de la API por atributos); la property lo mantiene cerrado en un closure.
Sesión vencida: wa-conversations:unauthorized
Ante un 401, el elemento despacha el evento wa-conversations:unauthorized (bubbles +
composed: llega a un listener en document aunque el elemento viva dentro de otro shadow root).
El detail es { status: 401 } — jamás lleva token ni URL.
Contrato de renovación: si el host rota el atributo token — síncrono en el handler, o async
dentro de ~3 s (el caso Keycloak updateToken()) — la request en vuelo se reintenta una
sola vez con el token nuevo, sin perder el estado de la UI. Sin listener o sin cambio, el visor
pinta su error de sesión normal; el elemento nunca reintenta solo.
document.addEventListener('wa-conversations:unauthorized', async () => {
const token = await renovarSesion(); // p. ej. keycloak.updateToken()
visor.setAttribute('token', token);
});Dale altura al elemento
El visor toma su altura del elemento (:host { height: 100% }), y height: 100% degrada a
auto si ningún ancestro define altura: sin un height del host, el visor crece con el contenido
y quien scrollea es la página. Dale al elemento (o a su contenedor) una altura explícita
(height: 100% en una cadena con altura, flex: 1 en un layout flex, etc.).
CORS
Un api-url en otro origen exige CORS_ALLOWED_ORIGINS correcto en el servicio
(wa_conversations_service) por ambiente, y el header authorization vuelve cada request una
request con preflight: el servicio debe permitir el header y el origen. Es configuración del
servicio, no del paquete — si el elemento "no carga" cross-origin, el diagnóstico empieza ahí.
El costo del bundle self-contained (WC-05)
pnpm build mide y reporta el tamaño en cada corrida (verify-externals). Al cierre de la Fase 7
(composer): 233.4 kB raw / 71.4 kB gzip (venía de 219.8 / 67.7 al cierre de la Fase 6). La
referencia discutida es 300 kB raw / 90 kB gzip,
deliberadamente SIN gate en CI (D-05): el número se reporta para que el costo sea visible, no
para romper builds.
Instalación
pnpm add @liftitapp/wa-conversationsSe publica al registry público de npm (registry.npmjs.org) bajo el scope @liftitapp, con
access public: el install es anónimo y ningún consumidor necesita credenciales ni .npmrc.
No hay ningún registry cerrado de por medio — es lo que permite que lms-web-app,
backoffice-v2-lite y FlexOS lo consuman sin pedirle un token a nadie.
El entry ./react necesita react y react-dom >=18 en el host: están declarados como
peerDependencies, así que el paquete usa los del host y nunca trae los suyos. El entry
./element no necesita nada: el bundle ya los trae adentro.
Elegir un entry
Un host elige UN entry, no los dos. Importar ./react y ./element en la misma página mete
dos copias de React en el documento y produce el error de hook inválido — el fallo más caro y menos
legible de este paquete, porque el stack trace apunta a React y no acá. Si aparece, el diagnóstico
es pnpm why react en el host: si hay más de una versión resuelta, el problema es éste.
| Si el host… | Usá | Por qué |
|---|---|---|
| ya tiene React 18+ y lo controla (lms-web-app) | ./react | React queda afuera del artefacto: una sola copia en la página |
| tiene otro React, o ninguno (backoffice-v2-lite, FlexOS) | ./element | El bundle es self-contained y no toca el React del host |
Dos costos honestos del empaquetado, para que nadie los descubra en producción:
- Los peers están marcados como opcionales para no llenar de warnings de install a quien sólo
usa
./element. La contracara es que quien use./reactsin React instalado no recibe ningún warning y falla en runtime. - Los subpaths del
exportsmap no resuelven con TypeScript anterior a 4.7.backoffice-v2-liteestá en3.3.3333, así que ese host consume el elemento con una etiqueta<script type="module">y no con un import tipado. Es una limitación aceptada a conciencia, no un bug.
ConversationList — el panel de la lista
Lo primero del visor que ya se puede montar de verdad (DEV-4986). El host inyecta su fetcher y el
panel pinta las conversaciones; la selección es controlada, o sea que quién está elegido lo
decide el host:
import { ConversationList } from '@liftitapp/wa-conversations/react';
const fetcher = async (request, options) => {
// La ruta, el tenant y la credencial son del HOST. El paquete recibe un descriptor semántico
// ({ resource: 'conversations', phone?, from?, to?, cursor? }) y nunca arma una URL.
const res = await fetch(miUrlPara(request), {
headers: { authorization: `Bearer ${token}` },
...options,
});
return res.json();
};
<ConversationList fetcher={fetcher} selectedPhone={elegido} onSelect={(item) => setElegido(item.phone)} />;Qué conviene saber antes de usarlo:
No hay props de ruta ni de credencial (
orgId,phoneNumberId,apiUrl,token) y no las va a haber: ver Regla de diseño que no se puede romper. El scope del inbox lo cierra elfetcherpor closure.El
service_statede cada fila se pinta tal como viene del backend, sin traducir. Es deliberado: ese vocabulario lo define cada servicio emisor (self_onboarding_whatsappmanda sus pasos de flujo, otro bot mandaría los suyos), y mapearlo a etiquetas lindas acoplaría el visor a un producto puntual. Un emisor sin flujo mandanully esa parte de la fila no se pinta.El CSS viaja adentro del componente, con todos los selectores prefijados con
.wa-list. No hay ningún.cssque importar y el paquete no trae dependencias de UI: monta igual enlms-web-appy enbackoffice-v2-lite.Angosto/ancho lo decide el ancho del contenedor, no el del viewport — el panel suele vivir en una columna de ~380 px de una pantalla de 1920. El mecanismo está exportado como
useContainerLayout(ref).La bandeja se refresca sola cada 10 s mientras está a la vista (DEV-5143), y el hilo también. Se ajusta con
refreshIntervalMsy se apaga conrefreshIntervalMs={0}, enConversationList, enConversationThready en elWaConversationscompleto. Quien pinte su propio markup se lleva el latido con los hooks (useConversations/useThreadaceptan la misma opción y exponenrefresh()), así que no hay que reimplementarlo afuera. Sin pestaña visible no hay requests.El switch de dos vistas todavía no está completo: "en pantalla angosta la lista y el hilo son dos vistas, no dos columnas" necesita el hilo, que es DEV-4987. Hoy existe el mecanismo y el layout angosto del panel; el shell que muestra el hilo en lugar de la lista llega con esa fase.
El composer — responderle al lifter (DEV-4995)
El composer de texto aparece debajo del hilo y sólo cuando el sobre del hilo trae
can_reply: true. No hay nada que activar: ConversationThread (y por lo tanto WaConversations
y el elemento) ya lo monta.
Lo que hay que saber antes de usarlo:
can_replyausente se lee comofalse. El campo es aditivo del contrato de envío del operador v0 y un servidor que no lo manda tampoco tiene/reply: el composer no se pinta y un operador de solo lectura ve el visor exactamente como antes. Cuandowa_conversations_service(DEV-4993) despliegue, se enciende solo.- Solo texto. Al lifter no se le mandan imágenes ni documentos: es decisión de producto, no un pendiente.
window_statetiene tres valores yclosed≠unverified.closedreemplaza el composer por un panel que explica que la conversación está fría y ofrece devolverle la conversación al bot;unverifiedsólo agrega un aviso de "reintentá" y no toca el composer.- «Devolver la conversación al bot» libera el takeover y nada más. Llama a
/release, que suelta el control para que el bot vuelva a manejar el hilo: no manda ninguna plantilla, no reactiva a nadie y no te devuelve la palabra a vos. Tras el 200 el composer sigue bloqueado — la ventana de 24 h se abre cuando la persona conteste. Es idempotente: liberar algo que no estaba tomado devuelve 200 igual. - La burbuja optimista se reconcilia por
client_message_id. Desde la extensión E3 del contrato del hilo,ThreadMessagetrae ese campo también enGET .../thread, así que un refetch que llegue antes de la respuesta del envío ya trae la saliente persistida con el mismo id ymergeThreadla une por identidad en vez de pintarla dos veces. Esnullen todo lo que no escribió un operador desde el visor (entrantes, salientes del bot, salientes previas al cambio): ahí siguen mandando el dedup porwamidy la detección de huérfanos pordirection. - El relanzamiento en frío con plantilla NO está en este visor. Ya existe en
lms-mf-lifters, en el header del drawer de la misma tabla del embudo donde el visor va embebido — ahí el MF tiene cargado el detalle del embudo (step/stage), que es debackoffice_apiy no del dominio conversación. El copy del panel frío lo menciona; el paquete no lo ejecuta.
Si inyectás tu propio fetcher, tiene que atender tres descriptores nuevos
WaRequest es una unión discriminada, así que TypeScript te lo va a decir al actualizar:
// POST {API}/conversations/{phone_number_id}/{phone}/reply
// body: { text, client_message_id } ← el snake_case lo armás vos, el descriptor es camelCase
{ resource: 'reply', phone, text, clientMessageId }
// POST {API}/conversations/{phone_number_id}/{phone}/release (sin cuerpo, 200 OK)
{ resource: 'release', phone }
// POST {API}/conversations/{phone_number_id}/{phone}/suggestion/discard (sin cuerpo, 200 OK)
{ resource: 'discardSuggestion', phone }El tercero es POST y no DELETE, y no es capricho: el CORS del store admite GET y POST, así que
un DELETE moriría en el preflight del navegador funcionando perfecto con curl — se leería
como un bug del front y no lo sería. Un host que no lo implemente nunca lo recibe, salvo que el
store empiece a emitir sugerencias (ver abajo).
Y el rechazo tiene que llevar el cuerpo, no sólo el status: window_closed y
window_unverified comparten el 409 y el contrato manda decidir por error_code. Lanzá un
WaFetchError (exportado por el paquete) o cualquier error con el cuerpo parseado colgado de
.body; sin estructura, el paquete degrada a "reintentable" y no puede distinguir los dos casos.
import { WaFetchError } from '@liftitapp/wa-conversations/react';
if (!res.ok) throw new WaFetchError(res.status, `${res.status}`, await res.json());Para pintar tu propia caja de texto, el estado sale por useComposer — ahí viven las reglas del
contrato (decidir por error_code, respetar retryable, un client_message_id por mensaje).
La burbuja de sugerencia del bot
Durante el takeover el bot sigue trabajando, pero su voz está cortada: el mensaje que produce muere en el gate y el operador no se entera de que existe. El visor lo muestra como una burbuja atenuada, sin tick y con la leyenda de que no se envió, con dos acciones: «Usar este mensaje» —que precarga el texto en el composer, editable, y no manda nada— y «Descartar».
Tres cosas que hay que saber para consumirlo:
- La única marca de una sugerencia es
status: "suggested". No hay ninguna clave nueva en el contrato del hilo: viaja por el mismoThreadMessagede siempre, conidyclient_message_idennull(nunca se mandó y no la escribió un operador). - Si el operador no hace nada, el bot la emite igual cuando la conversación vuelva a él. Ésa es la red de seguridad, y por eso «Descartar» existe: apaga el pendiente del bot, no sólo la burbuja.
- 🔴 Si pintás el hilo con tu propio markup (con
useThreaden vez deConversationThread), tenés que filtrar o marcar las sugerencias conisSuggestion. Llegan condirection: "out", así que un render ingenuo las pinta como una saliente enviada y el operador va a creer que el bot ya le habló a la persona — el opuesto exacto de la verdad.isSuggestionySUGGESTION_STATUSsalen del paquete justamente para eso.
import { isSuggestion } from '@liftitapp/wa-conversations/react';
const visibles = messages.filter((m) => !isSuggestion(m)); // o pintalas distinto, pero pintalasPor qué es un paquete y no un módulo del LMS
Porque Felipe lo completa hacia paridad desde FlexOS (Next.js) cuando extraiga su inbox. Un
directorio dentro de lms-web-app no lo puede consumir. Por eso es repo propio y se publica a npm
público bajo el scope @liftitapp con access public — no un directorio del LMS ni el single-spa
legacy. El único precedente de publicación de la org es @liftitapp/lms-http-client, que también
vive en el registry público (@liftitapp/lms-mf-ui no es molde de esto: es private: true y
se despliega a un bucket de GCS).
Regla de diseño que no se puede romper
El core (src/core/) nunca arma URLs ni conoce la autenticación: recibe un fetcher
inyectado y le pasa descriptores semánticos. El único lugar del paquete autorizado a mapear
descriptores→URL con auth es el default-fetcher del entry ./element (src/element/), el
adaptador que traduce los atributos del Web Component (reescrito en la Fase 6 por D-01/D-02). Si
el host inyecta su propio fetcher — prop en ./react, property en el elemento — el paquete no
ve ni URLs ni tokens.
Eso es lo que permite que el mismo core sirva a lms-web-app, a backoffice-v2-lite y a FlexOS,
cuyo backend scopea por organización en la ruta mientras el nuestro scopea por phone_number_id.
Si algún componente del core recibe un orgId o un phoneNumberId como prop y construye una ruta
con él, esa compatibilidad se pierde. scripts/verify-externals.mjs lo asserta en cada build.
Contexto
- Spike: DEV-4969
- Contrato del hilo: ThreadMessage / LeadThreadResponse v0
- Backend:
../wa_conversations_service - Host:
../lms-web-app
Setup
source ~/.nvm/nvm.sh && nvm use && pnpm install --prefer-offlinePublicar
Se publica a mano, desde tu máquina. No hay workflow de release, y no es un olvido: hubo uno
basado en el Trusted Publishing de npm (OIDC desde GitHub Actions) y se sacó. Exigía un paso de
registro en npmjs.com que nunca se hizo, así que los dos intentos —v0.4.0 y v0.5.0— corrieron
el pipeline entero y murieron en el npm publish con ENEEDAUTH. Un pipeline que sólo puede
fallar es peor que no tenerlo: entrena a ignorar el rojo. Es además lo que ya hace el otro paquete
publicable de la org, lms-http-client.
source ~/.nvm/nvm.sh && nvm use
npm login # una vez por máquina
npm version minor # bumpea package.json y crea el tag vX.Y.Z
npm publish # prepublishOnly corre los cinco gates
git push --follow-tagsprepublishOnly encadena typecheck → lint → test → build → verify:tarball, y ninguno de los
cinco es decorativo. Los dos últimos son los que atrapan lo irreversible:
pnpm buildcorrepublint,attwyverify-externals— que React quede afuera dedist/react/y adentro dedist/element/, y que los dos entries tengan sus.d.ts.pnpm verify:tarballasserta que no viaje nada fuera dedist/.contract/thread-v0.fixture.jsonlleva teléfonos y nombres reales y.planning/es documentación interna: esto va a un registry público. También frena una prerelease que fuera a publicarse bajolatest.
⚠️ npm sólo deja despublicar dentro de las 72 horas y sin dependientes. Lo que suba es prácticamente permanente — de ahí que los gates fallen en vez de avisar.
Prereleases
Una versión con guion (1.0.0-rc.1) tiene que ir al dist-tag next, nunca a latest:
npm publish --tag nextlatest es lo que se lleva quien haga npm i @liftitapp/wa-conversations sin pedir versión, así
que publicar un rc ahí lo convierte en la versión por defecto de todos los consumidores. El gate
lo impide y te dice el comando correcto.
Si alguna vez se vuelve a publicar desde CI
Hace falta registrar el Trusted Publisher en npmjs.com (paquete → Settings → Trusted
Publisher, org Liftitapp, repo wa-conversations, el workflow que publique). Ojo con una
trampa que la doc de npm no cubre: no está documentado cómo registrarlo para un paquete que
todavía no existe en el registry, así que es probable que el primer publish necesite hacerse
igual a mano y que el OIDC tome el relevo recién después.
Lo que no hay que hacer es meter un NPM_TOKEN en los secretos del repo. npm publish
encadena prepublishOnly, así que eslint, vitest, tsc, tsdown y las transitivas de las devDeps
correrían con un token de escritura al scope en process.env. Si no queda otra, que sea un
token granular acotado exclusivamente a @liftitapp/wa-conversations y publicando con
--ignore-scripts.
