@bircleai/widget-protocol 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,657 @@
1
+ /**
2
+ * `@bircleai/widget-protocol` — contrato compartido del Chat Gateway de BircleAI
3
+ * (endpoints `/v1/widget/*` + WebSocket). Fuente de verdad del contrato DENTRO
4
+ * DE ESTE REPO: hoy lo consumen el core del SDK y los wrappers de framework
5
+ * (React); a futuro lo consumirán también el widget web embebible y el SDK
6
+ * mobile. No inventar campos: reflejar exactamente lo que expone el Gateway.
7
+ *
8
+ * POR QUÉ este paquete: el widget web mantiene HOY su PROPIA copia del contrato
9
+ * (`bircle-web-widget/src/protocol/index.ts`) y las dos copias divergieron — el
10
+ * widget tenía media (`content_type`/`caption` en el request, `UploadRequest`/
11
+ * `UploadResponse`) y campos de theme que acá no existían, y cada lado tiene su
12
+ * propio type-guard del MISMO envelope de WS con criterios distintos de
13
+ * aceptación (el del widget, por ejemplo, no valida `ts` en absoluto). Eso es un
14
+ * bug latente: un evento que un lado acepta el otro lo descarta EN SILENCIO.
15
+ * Este archivo es el superset unificado; cualquier campo nuevo se agrega ACÁ
16
+ * primero.
17
+ *
18
+ * ALCANCE REAL DEL "GUARD ÚNICO" (no sobrevender): mientras el paquete no esté
19
+ * publicado, el guard único vale SOLO para los consumidores de este monorepo. La
20
+ * copia del widget web sigue viva e independiente.
21
+ * TODO (follow-up, ver tarea "SDK follow-ups"): publicar `@bircleai/widget-protocol`
22
+ * en el registry privado y reemplazar `bircle-web-widget/src/protocol/index.ts`
23
+ * por un import de este paquete — recién ahí la unificación es efectiva.
24
+ */
25
+ /** `POST /v1/widget/session` — body. */
26
+ interface SessionRequest {
27
+ embedKey: string;
28
+ /** Token del challenge (WAF Challenge / Turnstile en prod; DevChallengeVerifier en staging). */
29
+ challengeToken?: string;
30
+ /**
31
+ * Origen del sitio/app que embebe el chat, declarado por el CLIENTE. Es el
32
+ * valor que el Gateway valida contra `allowed_origins` del tenant cuando el
33
+ * header `Origin` NO es el del tenant (caso del frame servido desde nuestro
34
+ * CDN, y caso de una app nativa).
35
+ *
36
+ * OJO — declarar esto NO reemplaza al header `Origin`: `bootstrapWidgetSession`
37
+ * rechaza con `origin_not_allowed` ANTES de mirar `host_origin` si el request
38
+ * llega sin `Origin` (deny-by-default explícito, para que un cliente no-browser
39
+ * no obtenga token con solo agregar un campo al body). Ver el bloque
40
+ * "ORIGIN EN CLIENTES NATIVOS" del README del SDK.
41
+ */
42
+ host_origin?: string;
43
+ /**
44
+ * Identificador OPACO y persistente del visitante, generado y guardado por el
45
+ * cliente (`localStorage` en web, `AsyncStorage`/Keychain en mobile). Si cumple
46
+ * el formato del contrato (`^[A-Za-z0-9_-]{22,64}$`) y el Gateway tiene el
47
+ * secreto configurado, el `user_ref` se deriva determinísticamente de él ⇒ el
48
+ * MISMO usuario retoma SU conversación entre visitas / reinicios de la app.
49
+ *
50
+ * Un valor ausente o inválido NO falla la request: el Gateway cae al `user_ref`
51
+ * aleatorio de siempre — o sea, la continuidad se pierde EN SILENCIO. Por eso
52
+ * el formato lo valida también el cliente (`isValidVisitorId` en el core).
53
+ *
54
+ * SEGURIDAD: es un bearer de facto (quien lo tenga lee esa conversación). No
55
+ * loguearlo, no mandarlo a terceros, no ponerlo en una URL.
56
+ */
57
+ visitor_id?: string;
58
+ /**
59
+ * Id del usuario LOGUEADO en el sistema del cliente (1..128 chars, sin espacios
60
+ * en los bordes). Cuando viene con una `user_hash` que el Gateway puede
61
+ * verificar, el `user_ref` se deriva de ESTE valor y no del origen ⇒ la misma
62
+ * persona retoma su conversación desde cualquier browser o dispositivo.
63
+ *
64
+ * Un valor vacío o de solo espacios NO es un error: el Gateway lo trata como
65
+ * AUSENTE ("no hay nadie logueado") y da una sesión anónima. Eso permite montar
66
+ * el chat en una página donde el login es opcional sin ramificar la integración.
67
+ *
68
+ * ⚠️ NUNCA se manda solo: sin `user_hash` verificable el Gateway responde 403
69
+ * (ver ahí).
70
+ */
71
+ user_id?: string;
72
+ /**
73
+ * Firma del `user_id`: HMAC-SHA256 en HEX (64 chars, case-insensitive) del
74
+ * `user_id` con el identity secret del tenant.
75
+ *
76
+ * POR QUÉ HACE FALTA UNA FIRMA: sin ella, "soy el usuario 42" sería un dato que
77
+ * declara el cliente, y cualquiera leería la conversación del usuario 42
78
+ * cambiando un valor. El secreto vive SOLO en el backend del cliente y el hash
79
+ * lo calcula ESE backend — calcularlo en el browser o en la app significa poner
80
+ * el secreto ahí, y entonces la firma no prueba nada.
81
+ *
82
+ * FALLA CERRADO A PROPÓSITO: si viene un `user_id` cuya firma el Gateway no
83
+ * puede verificar (falta, no matchea, el tenant no tiene secreto configurado),
84
+ * la respuesta es 403 — NO degrada a sesión anónima. Degradar en silencio haría
85
+ * que el sitio crea que ató la conversación a su usuario logueado cuando en
86
+ * realidad quedó suelta.
87
+ */
88
+ user_hash?: string;
89
+ }
90
+ /** `POST /v1/widget/session` — respuesta 200. */
91
+ interface SessionResponse {
92
+ token: string;
93
+ expiresInSeconds?: number;
94
+ /**
95
+ * Refresh token para renovar la sesión SIN volver a resolver el challenge
96
+ * (`POST /v1/widget/session/refresh`).
97
+ *
98
+ * POR QUÉ EXISTE (y por qué el cliente lo tiene que usar): la renovación de
99
+ * hoy es reactiva y consiste en RE-BOOTSTRAPEAR mandando OTRA VEZ el mismo
100
+ * `challengeToken`. Eso solo funciona porque el challenge de staging es
101
+ * estático. Un challenge REAL (Turnstile / WAF Challenge) es ONE-SHOT: el
102
+ * segundo uso del mismo token lo rechaza el proveedor, así que el día que se
103
+ * active, el primer 401 dejaría al cliente en un 403 permanente.
104
+ *
105
+ * OPCIONAL A PROPÓSITO: el Gateway lo emite solo si el operador tiene prendido
106
+ * `WIDGET_REFRESH_TOKEN_ENABLED` (default true, pero apagable). Con la feature
107
+ * apagada la respuesta es IDÉNTICA a la de antes —el campo ni aparece— y el
108
+ * cliente tiene que seguir funcionando con el re-bootstrap de siempre.
109
+ *
110
+ * ⚠️ El nombre en el wire es `refreshToken` (camelCase), igual que `token` y
111
+ * `expiresInSeconds` — NO `refresh_token`. El endpoint de canje sí acepta las
112
+ * dos formas en el body, pero la RESPUESTA del bootstrap usa solo esta.
113
+ *
114
+ * SEGURIDAD: es un bearer de larga vida. Va SOLO en memoria (nunca
115
+ * localStorage, cookie, AsyncStorage ni Keychain), nunca en una URL y nunca en
116
+ * un log. Tiene un `scope` propio (`widget:refresh`): usarlo como token de
117
+ * sesión en `/messages` da 403.
118
+ */
119
+ refreshToken?: string;
120
+ /** TTL del `refreshToken` en segundos. Solo viene si vino `refreshToken`. */
121
+ refreshExpiresInSeconds?: number;
122
+ }
123
+ /**
124
+ * `POST /v1/widget/session/refresh` — body. Canjea un refresh token por un par
125
+ * (sesión + refresh) NUEVO, sin authorizer y SIN challenge.
126
+ *
127
+ * El token va en el BODY, no en `Authorization`: el Gateway acepta las dos
128
+ * formas, pero mandarlo como bearer invita a que un interceptor/proxy lo trate
129
+ * como credencial de sesión y lo reenvíe a otras rutas. Acá es un dato del
130
+ * request, no una credencial de transporte.
131
+ *
132
+ * EL ORIGEN SÍ IMPORTA: `refreshWidgetSession` compara el `Origin` del request
133
+ * contra el claim `origin` que quedó firmado en el token (`origin_mismatch`) —
134
+ * el MISMO pinning que el resto del gateway. En browser lo pone el browser; en
135
+ * React Native NO hay `Origin`, así que el cliente tiene que mandarlo a mano
136
+ * (ver `appOrigin` en `@bircleai/chat-widget-core`). Sin eso, el canje da 401.
137
+ */
138
+ interface SessionRefreshRequest {
139
+ refreshToken: string;
140
+ }
141
+ /**
142
+ * `POST /v1/widget/session/refresh` — respuesta 200.
143
+ *
144
+ * A diferencia de `SessionResponse`, acá los cuatro campos son OBLIGATORIOS: si
145
+ * el endpoint contesta 200 es porque emitió el par completo (ver
146
+ * `handleRefreshRequest`). Un backend que no tenga la feature no devuelve un 200
147
+ * incompleto: devuelve 404/401 y el cliente cae al re-bootstrap.
148
+ *
149
+ * ROTACIÓN DE UN SOLO USO (lo que hace delicado al cliente): el refresh que se
150
+ * canjeó queda QUEMADO — reusarlo da 401. Por eso el `refreshToken` de esta
151
+ * respuesta REEMPLAZA al anterior, y por eso no puede haber dos canjes
152
+ * concurrentes del mismo token (el segundo pierde la sesión). El `user_ref` y la
153
+ * familia (`sid`) se conservan: el visitante no pierde su conversación.
154
+ */
155
+ interface SessionRefreshResponse {
156
+ token: string;
157
+ expiresInSeconds: number;
158
+ refreshToken: string;
159
+ refreshExpiresInSeconds: number;
160
+ }
161
+ /** `POST /v1/widget/messages` — body. El tenant y el conversation_id se resuelven server-side desde el JWT. */
162
+ /**
163
+ * `GET /v1/widget/messages` — historial de la conversación del token, para
164
+ * rehidratar la UI al reabrir el chat.
165
+ *
166
+ * La media viene RE-FIRMADA con una presignada GET fresca cuando el Gateway
167
+ * puede hacerlo; si no puede, llega saneada (sin firma) y el cliente muestra un
168
+ * placeholder en vez de un 403.
169
+ */
170
+ interface HistoryResponse {
171
+ messages: HistoryMessageDto[];
172
+ }
173
+ interface HistoryMessageDto {
174
+ role: "user" | "assistant";
175
+ content: string;
176
+ content_type?: string;
177
+ caption?: string;
178
+ /** ISO-8601. Opcional: los mensajes viejos pueden no tenerlo. */
179
+ ts?: string;
180
+ /**
181
+ * Botones del mensaje, si el server los persistió.
182
+ *
183
+ * Va en el historial y no sólo en el evento del WebSocket para que reabrir el
184
+ * chat no borre los botones del último mensaje: si el usuario cerró sin
185
+ * elegir, al volver tiene que poder elegir igual.
186
+ */
187
+ quick_replies?: QuickReply[];
188
+ /** Ausente ⇒ `bot`. Ver `MessageAuthorKind`. */
189
+ author?: MessageAuthorKind;
190
+ }
191
+ /**
192
+ * Alias históricos: el widget web nombraba así a los tipos del refresh. Se
193
+ * mantienen para que migrar al paquete no obligue a tocar todos sus call sites
194
+ * en el mismo commit.
195
+ */
196
+ type RefreshRequest = SessionRefreshRequest;
197
+ type RefreshResponse = SessionRefreshResponse;
198
+ interface MessageRequest {
199
+ content: string;
200
+ turn_id: string;
201
+ /**
202
+ * Ausente ⇒ texto (backward-compat: el Gateway trata el mensaje igual que
203
+ * hoy). Para media, `content` lleva la `media_url` devuelta por
204
+ * `POST /v1/widget/uploads` y `content_type` el kind del media.
205
+ */
206
+ content_type?: "text" | MediaContentType;
207
+ /** Leyenda opcional del media (el Gateway la valida y la UI la renderiza como textContent, nunca como markup). */
208
+ caption?: string;
209
+ }
210
+ /** `POST /v1/widget/messages` — respuesta 202 (la respuesta del agente llega por WebSocket). */
211
+ interface MessageAccepted {
212
+ accepted: true;
213
+ turn_id: string;
214
+ }
215
+ /** Tipos de contenido de un mensaje (cross-canal). Ausente/"text" ⇒ texto; para media la URL va en `content`. */
216
+ type MediaContentType = "image" | "audio" | "video" | "document";
217
+ /**
218
+ * Kinds de media que el SDK sabe renderizar HOY. Exportado como const para que
219
+ * el narrowing del normalizador y los tests usen la MISMA lista (sin duplicarla).
220
+ */
221
+ declare const MEDIA_CONTENT_TYPES: readonly ["image", "audio", "video", "document"];
222
+ /**
223
+ * ¿El valor crudo del wire es un kind de media conocido?
224
+ *
225
+ * POR QUÉ existe: el guard del envelope valida TIPOS, no dominios (un kind nuevo
226
+ * del server no debe romper a los clientes viejos), así que `content_type` crudo
227
+ * es `string`. Pero el tipo canónico promete `"text" | MediaContentType`: sin
228
+ * este narrowing, un `"sticker"` del server viajaría con ese valor hasta un
229
+ * `switch` exhaustivo del consumidor, que TS considera imposible. Acá se cumple
230
+ * de verdad el "kind desconocido ⇒ el renderer degrada a texto".
231
+ */
232
+ declare function isMediaContentType(v: unknown): v is MediaContentType;
233
+ /**
234
+ * `POST /v1/widget/uploads` — body. Pide una URL presignada de subida directa a
235
+ * S3 (el binario NUNCA pasa por el Gateway). El tenant se resuelve server-side
236
+ * desde el JWT (header `authorization: Bearer <session token>`).
237
+ *
238
+ * `content_type` va contra una allow-list ESTRICTA server-side (imagen/audio/
239
+ * video/pdf): un tipo fuera de la lista → 400. `size_bytes` es un hint
240
+ * OPCIONAL, validado best-effort (<= 10 MiB); el presigned PUT no firma
241
+ * `Content-Length`, así que NO es un tope duro.
242
+ */
243
+ interface UploadRequest {
244
+ content_type: string;
245
+ /** Tamaño del archivo en bytes (hint para que el server pueda acotar el presigned PUT). */
246
+ size_bytes?: number;
247
+ }
248
+ /**
249
+ * `POST /v1/widget/uploads` — respuesta 200. `upload_url` es el presigned PUT
250
+ * (subida directa a S3, con el MISMO header `content-type` que se pidió);
251
+ * `media_url` es la URL final (https, bucket privado) con la que después se
252
+ * manda el mensaje en `MessageRequest.content`.
253
+ *
254
+ * `expires_in` (segundos) hoy el Gateway lo manda SIEMPRE, pero se tipa
255
+ * opcional a propósito: es un dato informativo y no queremos que el cliente
256
+ * dependa de su presencia.
257
+ */
258
+ interface UploadResponse {
259
+ upload_url: string;
260
+ media_url: string;
261
+ media_kind: MediaContentType;
262
+ expires_in?: number;
263
+ }
264
+ /**
265
+ * Espejo cliente-side de la allow-list ESTRICTA de MIME types de
266
+ * `WidgetChatGateway/uploads.ts::ALLOWED_UPLOAD_CONTENT_TYPES`.
267
+ *
268
+ * POR QUÉ existe una copia (y por qué no es una duplicación gratuita): el
269
+ * server es la fuente de verdad y rechaza con 400 cualquier tipo fuera de la
270
+ * lista. En web eso es un molestia; en MOBILE es un pozo, porque el MIME lo
271
+ * elige el RECORDER/PICKER del sistema y varía por plataforma — un
272
+ * `expo-av` mal configurado en Android graba `.3gp` (`audio/3gpp`), que NO está
273
+ * en la lista, y el dev se come un 400 opaco DESPUÉS de que el usuario grabó la
274
+ * nota de voz. Con este mapa el SDK falla antes de pedir el presigned, con un
275
+ * error que dice qué tipo mandó y cuáles se aceptan.
276
+ *
277
+ * Si el Gateway agrega un tipo, esta lista queda CORTA (rechaza de más), nunca
278
+ * larga: el server sigue validando igual. Es el sentido seguro del desfasaje.
279
+ */
280
+ declare const ALLOWED_UPLOAD_CONTENT_TYPES: Readonly<Record<string, MediaContentType>>;
281
+ /**
282
+ * Tope que el Gateway aplica al hint `size_bytes` (10 MiB). NO es un tope duro
283
+ * de S3 (el presigned PUT no firma `Content-Length`), pero mandar un
284
+ * `size_bytes` mayor da 400, así que el cliente lo chequea antes.
285
+ */
286
+ declare const MAX_UPLOAD_SIZE_BYTES: number;
287
+ /** `true` si el Gateway acepta ese MIME en `POST /v1/widget/uploads`. */
288
+ declare function isAllowedUploadContentType(contentType: string): boolean;
289
+ /** Kind de media que el Gateway va a asignar a ese MIME, o `null` si no está permitido. */
290
+ declare function mediaKindForContentType(contentType: string): MediaContentType | null;
291
+ /**
292
+ * `GET /v1/widget/theme?embedKey=` — solo campos de UI (branding por proyecto).
293
+ *
294
+ * IMPORTANTE — qué manda el server y qué NO: el Gateway arma la respuesta con
295
+ * un WHITELIST EXPLÍCITO (`WidgetChatGateway/theme.ts::toUiView`), no con un
296
+ * spread del theme config crudo. Ese whitelist expone los campos de BRANDING
297
+ * (ver `SERVER_PROVIDED_THEME_FIELDS`) y stripea todo lo demás.
298
+ *
299
+ * POR QUÉ EL WHITELIST CRECIÓ A TODO EL BRANDING: hasta la 0.3.0 sólo viajaban
300
+ * seis campos y el resto (`assistantName`, `colorScheme`, `accentColor`…) sólo
301
+ * se podía pasar en el `Bircle('init', …)` del sitio del cliente. O sea que
302
+ * cambiarle el nombre del asistente al chat de un banco le exigía a ELLOS tocar
303
+ * y redesplegar su sitio. Con el contrato completo eso se configura desde el
304
+ * Dashboard y llega solo.
305
+ *
306
+ * Los campos que SIGUEN siendo client-only están marcados abajo uno por uno,
307
+ * con el motivo. No son branding: son allow-lists de seguridad o decisiones de
308
+ * layout del host.
309
+ */
310
+ /**
311
+ * Configuración del challenge anti-bot que el cliente tiene que resolver ANTES
312
+ * del bootstrap.
313
+ *
314
+ * Viaja en el `/theme` y no en la config del snippet a propósito: cambiar o
315
+ * apagar el mecanismo no debe obligar a que cada cliente redespliegue su sitio.
316
+ */
317
+ interface ChallengeUiConfig {
318
+ /**
319
+ * Hoy sólo `"pow"` (prueba de trabajo). Un valor desconocido se ignora — el
320
+ * cliente no manda token y el Gateway, que es quien decide, rechaza. O sea:
321
+ * fail-open del cliente, fail-closed del server.
322
+ */
323
+ provider: string;
324
+ }
325
+ interface ThemeResponse {
326
+ /** Color de marca. `#rgb` o `#rrggbb`. Pasa por `safeThemeColor`. */
327
+ primaryColor?: string;
328
+ /** URL https del ícono del launcher. */
329
+ launcherIcon?: string;
330
+ position?: "bottom-right" | "bottom-left";
331
+ /** URL https del logo del header. */
332
+ headerLogo?: string;
333
+ locale?: string;
334
+ texts?: Record<string, string>;
335
+ /** Color de acento (botón de enviar, foco del input). Como `primaryColor`, pasa por `safeThemeColor`. */
336
+ accentColor?: string;
337
+ /** Familia tipográfica de la UI del chat (≤ 120 chars). */
338
+ fontFamily?: string;
339
+ /** Esquema de color forzado. Si se omite, se resuelve por `prefers-color-scheme` (fallback "light"). */
340
+ colorScheme?: "light" | "dark";
341
+ /** Nombre que muestra el header ("quién responde"). Se renderiza como `textContent`. */
342
+ assistantName?: string;
343
+ /** Subtítulo/estado del header cuando la conexión está abierta (ej. "En línea · responde al instante"). `textContent`. */
344
+ assistantTagline?: string;
345
+ /** URL del avatar del asistente (branding del cliente). Se setea como `.src` de un `<img>`, nunca interpolado en HTML. */
346
+ assistantAvatar?: string;
347
+ /** Etiqueta del launcher (burbuja de apertura). Consumida por el loader (R2). */
348
+ launcherLabel?: string;
349
+ /** `false` oculta el footer "Powered by BircleAI". Cualquier otro valor lo muestra. */
350
+ poweredBy?: boolean;
351
+ /**
352
+ * Hosts https permitidos para renderizar media rica (allow-list; lo consume la
353
+ * UI, no el core).
354
+ *
355
+ * NO entra al whitelist de branding A PROPÓSITO: es una decisión de SEGURIDAD,
356
+ * no de estética. Dejar que el theme del tenant la escriba equivale a dejar que
357
+ * el tenant amplíe desde dónde el widget carga contenido — el mismo motivo por
358
+ * el que `mediaBucketHost` se deriva server-side. Ver `toUiView`.
359
+ */
360
+ mediaHosts?: string[];
361
+ /**
362
+ * Host del bucket de media, para poder renderizar la del HISTORIAL — que el
363
+ * Gateway devuelve con una presignada GET fresca.
364
+ *
365
+ * Va aparte de `mediaHosts` a propósito: habilitar el historial NO debe
366
+ * ampliar la allow-list con la que se renderiza la media ENTRANTE del agente
367
+ * (contenido no confiable). Sin este campo la media histórica cae al
368
+ * placeholder — aditivo y safe-by-default.
369
+ */
370
+ mediaBucketHost?: string;
371
+ /**
372
+ * Challenge anti-bot a resolver antes del bootstrap. Ausente ⇒ no hay ninguno
373
+ * (staging con el verificador `dev`).
374
+ */
375
+ challenge?: ChallengeUiConfig;
376
+ /**
377
+ * Pantalla completa en teléfonos (debajo de 480px de ancho): el panel ocupa el
378
+ * viewport entero, sin bordes redondeados ni sombra, y el launcher se oculta
379
+ * mientras está abierto.
380
+ *
381
+ * Lo consume el LOADER del widget web, no la app del iframe: el tamaño y la
382
+ * posición del iframe los decide la página host (el frame no se puede
383
+ * redimensionar solo). En el SDK mobile no aplica.
384
+ */
385
+ mobileFullscreen?: boolean;
386
+ }
387
+ /**
388
+ * Campos que el Gateway SÍ devuelve en `GET /v1/widget/theme` (whitelist de
389
+ * `theme.ts::toUiView`). Exportado para que la UI/los tests puedan distinguir
390
+ * "lo que vino del server" de la config local sin duplicar la lista a mano.
391
+ *
392
+ * Es TODO el branding del contrato del tema (v1) y nada más. Lo que quedó
393
+ * afuera está afuera por un motivo, no por olvido:
394
+ * - `mediaHosts` / `mediaBucketHost`: allow-lists de seguridad. La segunda la
395
+ * DERIVA el Gateway de la misma fuente que el presigner, después del
396
+ * whitelist, justo para que un theme del tenant no la pueda inyectar.
397
+ * - `challenge`: lo decide el operador, no el tenant. Mismo tratamiento.
398
+ * - `mobileFullscreen`: layout del host web, no branding.
399
+ *
400
+ * Si se agrega un campo acá hay que agregarlo TAMBIÉN en las otras dos listas
401
+ * blancas (el `toUiView` del Gateway y la del widget web) — son defensa en
402
+ * profundidad a propósito, no una duplicación accidental.
403
+ */
404
+ declare const SERVER_PROVIDED_THEME_FIELDS: readonly ["primaryColor", "accentColor", "launcherIcon", "launcherLabel", "position", "headerLogo", "assistantName", "assistantTagline", "assistantAvatar", "fontFamily", "colorScheme", "poweredBy", "locale", "texts"];
405
+ /** Subconjunto de `ThemeResponse` que el server realmente puede mandar (ver `SERVER_PROVIDED_THEME_FIELDS`). */
406
+ type ServerThemeResponse = Pick<ThemeResponse, (typeof SERVER_PROVIDED_THEME_FIELDS)[number]>;
407
+ /** Tope de `assistantName` y `launcherLabel`. Más que esto no entra en un header de teléfono. */
408
+ declare const MAX_THEME_LABEL = 40;
409
+ /** Tope de `assistantTagline` (el subtítulo del header). */
410
+ declare const MAX_THEME_TAGLINE = 80;
411
+ /** Tope de `fontFamily` (una pila de fuentes razonable entra de sobra). */
412
+ declare const MAX_THEME_FONT_FAMILY = 120;
413
+ /** Tope de claves en `texts`. La UI tiene ~25 textos; 40 deja margen sin volverse un blob. */
414
+ declare const MAX_THEME_TEXT_KEYS = 40;
415
+ /** Tope de una clave de `texts`. */
416
+ declare const MAX_THEME_TEXT_KEY = 60;
417
+ /** Tope de un valor de `texts`. */
418
+ declare const MAX_THEME_TEXT_VALUE = 200;
419
+ /**
420
+ * Color usable para el tema: `#rgb` o `#rrggbb`, nada más.
421
+ *
422
+ * Devuelve `null` en vez de un default para que quien llama decida a qué caer
423
+ * (cada superficie tiene el suyo). Se rechaza todo lo demás —incluido
424
+ * `rgb()`/`hsl()`— porque en web el valor termina interpolado en una custom
425
+ * property de CSS: cuanto más chico el dominio aceptado, menos superficie hay
426
+ * para un `);…` inyectado. Es exactamente lo que valida el contrato del tema.
427
+ */
428
+ declare function safeThemeColor(value: unknown): string | null;
429
+ /**
430
+ * URL https absoluta, o `null`.
431
+ *
432
+ * `new URL` y no un regex: parsear es lo único que distingue de verdad un
433
+ * esquema de un prefijo que se le parece (`https:/\/evil` y compañía). Sólo
434
+ * `https:` — un `http://` en el logo hace que el browser marque contenido mixto
435
+ * (y en iOS ATS directamente falla el request), y `javascript:`/`data:` en algo
436
+ * que termina en un `<img src>` es un vector de XSS en el sitio del cliente.
437
+ */
438
+ declare function safeThemeUrl(value: unknown): string | null;
439
+ /**
440
+ * Tema del server, saneado campo por campo contra el contrato.
441
+ *
442
+ * ES EL ÚNICO CAMINO DE ENTRADA del branding remoto: lo que no sale de acá no
443
+ * debería aplicarse. Devuelve un objeto NUEVO con sólo los campos válidos —
444
+ * nunca el input— así ninguna clave extra del wire (una que el server agregue
445
+ * mañana, o una que un theme viejo tenga guardada) se cuela por un spread.
446
+ *
447
+ * NUNCA LANZA Y NUNCA RECHAZA EL TEMA ENTERO: un campo inválido se descarta y el
448
+ * consumidor cae a su default. Perder el branding completo —o peor, no pintar el
449
+ * chat— porque el `accentColor` está mal cargado sería mucho peor que mostrar el
450
+ * acento por default.
451
+ */
452
+ declare function sanitizeServerTheme(raw: unknown): ServerThemeResponse;
453
+ /**
454
+ * Evento entregado por el WebSocket del widget (respuesta del asistente o del
455
+ * agente humano), en su forma CANÓNICA/normalizada: `ts` garantizado y
456
+ * `turn_ids` garantizado `string[]`. Es lo que devuelve `parseWidgetEvent` y lo
457
+ * que consume la UI.
458
+ */
459
+ /**
460
+ * Botón que el agente ofrece para responder sin escribir.
461
+ *
462
+ * POR QUÉ IMPORTA: obligar a tipear es la fricción más grande de un chat de
463
+ * cobranzas. Un "Ver mi deuda" / "Hablar con una persona" a un toque cambia
464
+ * cuánta gente contesta, sobre todo en teléfono.
465
+ *
466
+ * ADITIVO Y SEGURO POR DEFECTO: un cliente que no conozca el campo lo ignora y
467
+ * sigue viendo el texto del mensaje, que tiene que ser autosuficiente — las
468
+ * quick replies son un atajo, nunca la única forma de seguir.
469
+ */
470
+ interface QuickReply {
471
+ /** Lo que ve el usuario. Se renderiza como texto plano, NUNCA como markup. */
472
+ label: string;
473
+ /**
474
+ * Lo que se manda al agente si el `label` no alcanza (un id, un código).
475
+ * Ausente ⇒ se manda el `label`.
476
+ */
477
+ value?: string;
478
+ }
479
+ /** Tope de botones por mensaje. Ver `MAX_QUICK_REPLIES`. */
480
+ declare const MAX_QUICK_REPLIES = 6;
481
+ /** Largo máximo de un `label`. Más que esto no entra en un chip en teléfono. */
482
+ declare const MAX_QUICK_REPLY_LABEL = 40;
483
+ /**
484
+ * Quick replies USABLES de una lista cruda.
485
+ *
486
+ * El contenido lo escribe el cerebro y llega por WebSocket: se sanea acá y no en
487
+ * cada cliente para que el widget y el SDK no puedan divergir en qué aceptan.
488
+ * Descarta lo inválido en vez de rechazar el mensaje entero — perder la
489
+ * respuesta del agente por un botón mal formado sería mucho peor.
490
+ */
491
+ declare function sanitizeQuickReplies(raw: unknown): QuickReply[];
492
+ /**
493
+ * Quién escribió un mensaje del lado del asistente.
494
+ *
495
+ * `bot` (o ausente) ⇒ el agente de IA. `human` ⇒ una persona del equipo del
496
+ * cliente que tomó la conversación (handover). El usuario TIENE que poder
497
+ * distinguirlos: creer que le contesta un bot cuando es una persona —o al revés—
498
+ * cambia cómo escribe y qué espera.
499
+ */
500
+ type MessageAuthorKind = "bot" | "human";
501
+ /** Quién atiende del otro lado. Lo consume el header del chat. */
502
+ interface AssistantIdentity {
503
+ kind: MessageAuthorKind;
504
+ /** Nombre a mostrar. Ausente ⇒ el cliente usa el del tema. */
505
+ name?: string;
506
+ /** URL https del avatar. Ausente ⇒ iniciales. */
507
+ avatar?: string;
508
+ }
509
+ /** Tope del nombre del agente. Más que esto no entra en el header de un teléfono. */
510
+ declare const MAX_AGENT_NAME = 40;
511
+ /**
512
+ * Identidad del agente humano, saneada.
513
+ *
514
+ * Se sanea acá y no en cada cliente por dos razones. La primera es que el widget
515
+ * y el SDK no puedan divergir en qué aceptan. La segunda es de SEGURIDAD: el
516
+ * `avatar` termina en un `<img src>` (web) y en un `<Image source>` (mobile), así
517
+ * que una URL `javascript:`/`data:` inyectada en el dato del agente sería un
518
+ * vector de XSS en el sitio del cliente. Sólo pasa `https:`.
519
+ *
520
+ * Devuelve `null` si no hay NADA usable: así el cliente cae a su propio default
521
+ * (iniciales, nombre genérico) en vez de mostrar un header vacío.
522
+ */
523
+ declare function sanitizeAssistantIdentity(raw: unknown, kind?: MessageAuthorKind): AssistantIdentity | null;
524
+ interface AssistantMessageEvent {
525
+ type: "assistant_message";
526
+ conversation_id: string;
527
+ content: string;
528
+ /** Ausente o "text" ⇒ texto; para media (`image`/`audio`/`video`/`document`) `content` lleva la URL. */
529
+ content_type?: "text" | MediaContentType;
530
+ /** Leyenda del media (texto plano; se renderiza como textContent, nunca como markup). */
531
+ caption?: string;
532
+ turn_ids: string[];
533
+ /** Epoch ms. El worker actual siempre lo manda; si faltara, `parseWidgetEvent` lo normaliza con el reloj local. */
534
+ ts: number;
535
+ /** Botones para responder sin escribir. Ver `QuickReply`. */
536
+ quick_replies?: QuickReply[];
537
+ /**
538
+ * Quién lo escribió. Ausente ⇒ `bot` (todo lo que existía antes de que hubiera
539
+ * handover es del agente de IA).
540
+ */
541
+ author?: MessageAuthorKind;
542
+ /** Identidad del humano que tomó la conversación. Sólo con `author: "human"`. */
543
+ agent?: AssistantIdentity;
544
+ }
545
+ /**
546
+ * Envelope TAL COMO PUEDE LLEGAR del wire, antes de normalizar. Difiere del
547
+ * canónico SOLO en lo que el guard tolerante NO garantiza, para no mentir en el
548
+ * tipo que se afirma:
549
+ * - `ts` OPCIONAL y `| null`: el server no lo garantiza como parte del contrato
550
+ * mínimo (hoy `WidgetInboundWorker` lo manda SIEMPRE, pero un productor nuevo
551
+ * —o un handover humano— podría no hacerlo). `| null` porque un productor en
552
+ * PYTHON (el futuro handover humano publicando al broadcaster) serializaría
553
+ * `None` como `null`, NO como campo ausente. Hoy ese productor no existe: es
554
+ * endurecimiento preventivo, no un bug vivo.
555
+ * - `content_type` como `string | null`: el guard valida TIPOS, no el dominio
556
+ * del valor (un kind nuevo del server no debe romper clientes viejos). El
557
+ * narrowing al dominio canónico lo hace `normalizeAssistantMessageEvent`.
558
+ * - `caption` `| null`: mismo motivo que `ts` (Python `None` ⇒ `null`).
559
+ * - `turn_ids` como `readonly unknown[]`: el guard solo verifica que sea un
560
+ * array; los elementos los filtra `normalizeAssistantMessageEvent`.
561
+ */
562
+ type RawAssistantMessageEvent = Omit<AssistantMessageEvent, "ts" | "turn_ids" | "content_type" | "caption"> & {
563
+ ts?: number | null;
564
+ content_type?: string | null;
565
+ caption?: string | null;
566
+ turn_ids: readonly unknown[];
567
+ };
568
+ type WidgetEvent = AssistantMessageEvent;
569
+ /** Union cruda del wire (hoy un solo tipo de evento; `typing`/etc. se sumarían acá). */
570
+ type RawWidgetEvent = RawAssistantMessageEvent;
571
+ /**
572
+ * ÚNICA validación de shape del envelope entrante del WS (nunca confía en la
573
+ * forma). Tolerante a propósito: rechaza solo lo que hace al evento INUTILIZABLE
574
+ * y deja pasar todo lo que la UI puede degradar sola. Antes existían dos guards
575
+ * distintos (SDK vs widget) sobre este MISMO envelope; ahora hay uno.
576
+ *
577
+ * Se valida (obligatorio — sin esto no hay evento que mostrar):
578
+ * - `type === "assistant_message"`: discrimina la union; otro tipo no es este evento.
579
+ * - `conversation_id` string: sin conversación no se puede rutear el mensaje.
580
+ * - `content` string: es el cuerpo (texto o URL del media).
581
+ * - `turn_ids` es Array: se usa para correlacionar/deduplicar turnos.
582
+ *
583
+ * NO se valida (tolerancia deliberada):
584
+ * - `ts`: OPCIONAL. Si viene con valor debe ser numérico; si falta NO se
585
+ * descarta el evento — `parseWidgetEvent` lo completa con el reloj local.
586
+ * Este era el bug latente: el guard viejo del SDK exigía
587
+ * `typeof ts === "number"` y tiraba a la basura, en silencio, un evento por lo
588
+ * demás perfecto.
589
+ * - elementos de `turn_ids`: alcanza con que sea un array. Un id basura
590
+ * (número, null) NO justifica perder la respuesta del agente;
591
+ * `normalizeAssistantMessageEvent` filtra los no-string.
592
+ * - dominio de `content_type`: solo se valida que sea string si viene con
593
+ * valor. Un kind desconocido (ej. "sticker") lo degrada el normalizador a
594
+ * texto — el guard valida TIPOS, no el dominio del valor (si no, un kind
595
+ * nuevo del server rompería a los clientes viejos).
596
+ * - campos extra desconocidos: se ignoran (forward-compat).
597
+ *
598
+ * `null` == AUSENCIA, no valor inválido (POR QUÉ): un `{"ts": null}` o
599
+ * `{"caption": null}` es una forma NORMAL de decir "este campo opcional no
600
+ * aplica", no un envelope corrupto. Tratarlo como inválido descartaría el evento
601
+ * ENTERO en silencio. Por eso los tres opcionales se chequean con
602
+ * `!== undefined && !== null`.
603
+ *
604
+ * ALCANCE (verificado, para no sobrevender esto): hoy el ÚNICO productor es
605
+ * TypeScript (`WidgetInboundWorker/worker.ts`), que manda `ts` SIEMPRE y nunca
606
+ * emite `content_type`/`caption` ⇒ hoy no se pierde ninguna respuesta por esta
607
+ * vía. Es defensa a futuro: un productor en PYTHON (el handover humano
608
+ * publicando al broadcaster) serializaría `None` como `null`, y ahí un guard
609
+ * estricto sí habría descartado el evento.
610
+ */
611
+ declare function isRawAssistantMessageEvent(v: unknown): v is RawAssistantMessageEvent;
612
+ /**
613
+ * Type guard del evento YA CANÓNICO (`AssistantMessageEvent`): además del shape
614
+ * validado por `isRawAssistantMessageEvent`, exige lo que la normalización
615
+ * garantiza — `ts` numérico y `turn_ids` todos string.
616
+ *
617
+ * POR QUÉ existen los dos: son preguntas distintas, no criterios divergentes.
618
+ * `isRawAssistantMessageEvent` = "¿esto es un envelope del wire que puedo
619
+ * aprovechar?" (tolerante, es el que usa el transporte). Este = "¿esto YA
620
+ * cumple el tipo canónico?" (para no mentir en el tipo que se afirma). Ambos
621
+ * comparten la MISMA validación de campos; la única diferencia es la frontera
622
+ * de normalización. Para consumir el WS usá `parseWidgetEvent`.
623
+ *
624
+ * Sobre los opcionales: el crudo tolera `null` y un `content_type` fuera del
625
+ * dominio, pero el tipo canónico promete `caption?: string` y
626
+ * `content_type?: "text" | MediaContentType`. Si acá no se chequeara eso, este
627
+ * guard afirmaría un tipo que el valor NO cumple.
628
+ */
629
+ declare function isAssistantMessageEvent(v: unknown): v is AssistantMessageEvent;
630
+ /**
631
+ * Normaliza un envelope crudo al evento canónico:
632
+ * - completa `ts` con el reloj local si el server no lo mandó (o mandó basura);
633
+ * - descarta los `turn_ids` que no sean string (en vez de perder el evento);
634
+ * - narrowea `content_type` al dominio conocido (kind raro ⇒ `"text"`).
635
+ * `now` es inyectable para tests.
636
+ *
637
+ * `ts` con sanity-check (`Number.isFinite` && `> 0`), no solo `typeof number`:
638
+ * un `0`, un negativo, un `NaN` o un `Infinity` pasarían el typeof y la UI
639
+ * renderizaría "1 ene 1970" (o una fecha inválida) como si fuera el dato real
640
+ * del server. Un timestamp imposible es lo mismo que un timestamp ausente: se
641
+ * completa con el reloj local, que al menos es plausible.
642
+ *
643
+ * Los spreads usan `!= null` (no `!== undefined`) A PROPÓSITO: cubre `undefined`
644
+ * Y `null` con una sola comparación, así un `caption: null` (que emitiría un
645
+ * productor Python futuro) NO se copia al evento canónico (tipo `caption?: string`).
646
+ * `exactOptionalPropertyTypes` obliga a copiarlos solo si están presentes (no se
647
+ * puede asignar `undefined` explícito).
648
+ */
649
+ declare function normalizeAssistantMessageEvent(raw: RawAssistantMessageEvent, now?: () => number): AssistantMessageEvent;
650
+ /**
651
+ * Parseo seguro de un mensaje crudo del WS (nunca lanza; devuelve `null` si no
652
+ * es un evento válido). Valida con el guard tolerante y devuelve el evento YA
653
+ * normalizado, así el consumidor siempre ve `ts: number` y `turn_ids: string[]`.
654
+ */
655
+ declare function parseWidgetEvent(raw: string, now?: () => number): WidgetEvent | null;
656
+
657
+ export { ALLOWED_UPLOAD_CONTENT_TYPES, type AssistantIdentity, type AssistantMessageEvent, type ChallengeUiConfig, type HistoryMessageDto, type HistoryResponse, MAX_AGENT_NAME, MAX_QUICK_REPLIES, MAX_QUICK_REPLY_LABEL, MAX_THEME_FONT_FAMILY, MAX_THEME_LABEL, MAX_THEME_TAGLINE, MAX_THEME_TEXT_KEY, MAX_THEME_TEXT_KEYS, MAX_THEME_TEXT_VALUE, MAX_UPLOAD_SIZE_BYTES, MEDIA_CONTENT_TYPES, type MediaContentType, type MessageAccepted, type MessageAuthorKind, type MessageRequest, type QuickReply, type RawAssistantMessageEvent, type RawWidgetEvent, type RefreshRequest, type RefreshResponse, SERVER_PROVIDED_THEME_FIELDS, type ServerThemeResponse, type SessionRefreshRequest, type SessionRefreshResponse, type SessionRequest, type SessionResponse, type ThemeResponse, type UploadRequest, type UploadResponse, type WidgetEvent, isAllowedUploadContentType, isAssistantMessageEvent, isMediaContentType, isRawAssistantMessageEvent, mediaKindForContentType, normalizeAssistantMessageEvent, parseWidgetEvent, safeThemeColor, safeThemeUrl, sanitizeAssistantIdentity, sanitizeQuickReplies, sanitizeServerTheme };