@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.
- package/LICENSE.md +50 -0
- package/README.md +13 -0
- package/dist/index.cjs +293 -0
- package/dist/index.d.cts +657 -0
- package/dist/index.d.ts +657 -0
- package/dist/index.js +244 -0
- package/package.json +50 -0
package/dist/index.d.cts
ADDED
|
@@ -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 };
|