@bircleai/widget-protocol 0.6.1 → 0.7.0

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/dist/index.cjs CHANGED
@@ -21,9 +21,16 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  ALLOWED_UPLOAD_CONTENT_TYPES: () => ALLOWED_UPLOAD_CONTENT_TYPES,
24
+ DEFAULT_SURVEY_TEXT_LEN: () => DEFAULT_SURVEY_TEXT_LEN,
24
25
  MAX_AGENT_NAME: () => MAX_AGENT_NAME,
25
26
  MAX_QUICK_REPLIES: () => MAX_QUICK_REPLIES,
26
27
  MAX_QUICK_REPLY_LABEL: () => MAX_QUICK_REPLY_LABEL,
28
+ MAX_SURVEY_CHOICE_OPTIONS: () => MAX_SURVEY_CHOICE_OPTIONS,
29
+ MAX_SURVEY_ID: () => MAX_SURVEY_ID,
30
+ MAX_SURVEY_LABEL: () => MAX_SURVEY_LABEL,
31
+ MAX_SURVEY_OPTION_LABEL: () => MAX_SURVEY_OPTION_LABEL,
32
+ MAX_SURVEY_STEPS: () => MAX_SURVEY_STEPS,
33
+ MAX_SURVEY_TEXT_LEN: () => MAX_SURVEY_TEXT_LEN,
27
34
  MAX_THEME_FONT_FAMILY: () => MAX_THEME_FONT_FAMILY,
28
35
  MAX_THEME_LABEL: () => MAX_THEME_LABEL,
29
36
  MAX_THEME_TAGLINE: () => MAX_THEME_TAGLINE,
@@ -44,7 +51,8 @@ __export(index_exports, {
44
51
  safeThemeUrl: () => safeThemeUrl,
45
52
  sanitizeAssistantIdentity: () => sanitizeAssistantIdentity,
46
53
  sanitizeQuickReplies: () => sanitizeQuickReplies,
47
- sanitizeServerTheme: () => sanitizeServerTheme
54
+ sanitizeServerTheme: () => sanitizeServerTheme,
55
+ sanitizeSurvey: () => sanitizeSurvey
48
56
  });
49
57
  module.exports = __toCommonJS(index_exports);
50
58
  var MEDIA_CONTENT_TYPES = [
@@ -131,6 +139,13 @@ function sanitizeThemeTexts(raw) {
131
139
  }
132
140
  return n > 0 ? out : null;
133
141
  }
142
+ function safeLauncherToken(v) {
143
+ if (typeof v !== "string") return null;
144
+ const t = v.trim();
145
+ if (t === "bircle" || t === "chat") return t;
146
+ if (t.length > 0 && t.length <= 8 && !t.includes(":") && !t.includes("/")) return t;
147
+ return null;
148
+ }
134
149
  function sanitizeServerTheme(raw) {
135
150
  if (typeof raw !== "object" || raw === null) return {};
136
151
  const o = raw;
@@ -139,7 +154,7 @@ function sanitizeServerTheme(raw) {
139
154
  if (primaryColor !== null) out.primaryColor = primaryColor;
140
155
  const accentColor = safeThemeColor(o.accentColor);
141
156
  if (accentColor !== null) out.accentColor = accentColor;
142
- const launcherIcon = safeThemeUrl(o.launcherIcon);
157
+ const launcherIcon = safeThemeUrl(o.launcherIcon) ?? safeLauncherToken(o.launcherIcon);
143
158
  if (launcherIcon !== null) out.launcherIcon = launcherIcon;
144
159
  const headerLogo = safeThemeUrl(o.headerLogo);
145
160
  if (headerLogo !== null) out.headerLogo = headerLogo;
@@ -203,6 +218,76 @@ function sanitizeQuickReplies(raw) {
203
218
  }
204
219
  return out;
205
220
  }
221
+ var MAX_SURVEY_STEPS = 5;
222
+ var MAX_SURVEY_CHOICE_OPTIONS = 10;
223
+ var MAX_SURVEY_LABEL = 100;
224
+ var MAX_SURVEY_OPTION_LABEL = 30;
225
+ var MAX_SURVEY_ID = 64;
226
+ var MAX_SURVEY_TEXT_LEN = 1e3;
227
+ var DEFAULT_SURVEY_TEXT_LEN = 500;
228
+ function surveyId(value, max = MAX_SURVEY_ID) {
229
+ if (typeof value !== "string") return null;
230
+ const v = value.trim();
231
+ return v !== "" && v.length <= max ? v : null;
232
+ }
233
+ function sanitizeSurveyStep(raw) {
234
+ if (typeof raw !== "object" || raw === null) return null;
235
+ const o = raw;
236
+ const key = surveyId(o.key);
237
+ const label = surveyId(o.label, MAX_SURVEY_LABEL);
238
+ if (key === null || label === null) return null;
239
+ switch (o.type) {
240
+ case "stars":
241
+ return { type: "stars", key, label };
242
+ case "choice": {
243
+ if (!Array.isArray(o.options)) return null;
244
+ if (o.options.length === 0 || o.options.length > MAX_SURVEY_CHOICE_OPTIONS) return null;
245
+ const options = [];
246
+ for (const item of o.options) {
247
+ if (typeof item !== "object" || item === null) return null;
248
+ const opt = item;
249
+ const id = surveyId(opt.id);
250
+ const optLabel = surveyId(opt.label, MAX_SURVEY_OPTION_LABEL);
251
+ if (id === null || optLabel === null) return null;
252
+ options.push({ id, label: optLabel });
253
+ }
254
+ return { type: "choice", key, label, options };
255
+ }
256
+ case "text": {
257
+ const step = { type: "text", key, label };
258
+ if (typeof o.max_len === "number" && Number.isInteger(o.max_len) && o.max_len > 0 && o.max_len <= MAX_SURVEY_TEXT_LEN) {
259
+ step.max_len = o.max_len;
260
+ }
261
+ return step;
262
+ }
263
+ default:
264
+ return null;
265
+ }
266
+ }
267
+ function sanitizeSurvey(raw) {
268
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
269
+ const o = raw;
270
+ const survey_id = surveyId(o.survey_id);
271
+ const survey_msg_id = surveyId(o.survey_msg_id);
272
+ if (survey_id === null || survey_msg_id === null) return null;
273
+ if (typeof o.version !== "number" || !Number.isInteger(o.version) || o.version <= 0) return null;
274
+ if (!Array.isArray(o.steps)) return null;
275
+ if (o.steps.length === 0 || o.steps.length > MAX_SURVEY_STEPS) return null;
276
+ const steps = [];
277
+ for (const rawStep of o.steps) {
278
+ const step = sanitizeSurveyStep(rawStep);
279
+ if (step === null) return null;
280
+ steps.push(step);
281
+ }
282
+ let answered;
283
+ if (o.answered === void 0 || o.answered === null) answered = false;
284
+ else if (typeof o.answered === "boolean") answered = o.answered;
285
+ else return null;
286
+ const out = { survey_id, version: o.version, survey_msg_id, steps, answered };
287
+ const case_ref = surveyId(o.case_ref, MAX_SURVEY_LABEL);
288
+ if (case_ref !== null) out.case_ref = case_ref;
289
+ return out;
290
+ }
206
291
  var MAX_AGENT_NAME = 40;
207
292
  function sanitizeAssistantIdentity(raw, kind = "human") {
208
293
  if (typeof raw !== "object" || raw === null) return null;
@@ -243,6 +328,7 @@ function isAssistantMessageEvent(v) {
243
328
  if (v.caption !== void 0 && typeof v.caption !== "string") return false;
244
329
  if (v.content_type !== void 0 && v.content_type !== "text" && !isMediaContentType(v.content_type))
245
330
  return false;
331
+ if (v.survey !== void 0 && sanitizeSurvey(v.survey) === null) return false;
246
332
  return v.turn_ids.every((t) => typeof t === "string");
247
333
  }
248
334
  function normalizeAssistantMessageEvent(raw, now = Date.now) {
@@ -261,6 +347,13 @@ function normalizeAssistantMessageEvent(raw, now = Date.now) {
261
347
  const qr = sanitizeQuickReplies(raw.quick_replies);
262
348
  return qr.length > 0 ? { quick_replies: qr } : {};
263
349
  })(),
350
+ // La FUNCIÓN es la barrera (lección FAQs): un `survey` que no pasa el saneo
351
+ // no viaja — el mensaje conserva su `content` de texto fallback, así que
352
+ // una encuesta corrupta nunca cuesta la respuesta del agente.
353
+ ...(() => {
354
+ const sv = sanitizeSurvey(raw.survey);
355
+ return sv !== null ? { survey: sv } : {};
356
+ })(),
264
357
  // Cualquier valor que no sea exactamente "human" cuenta como bot: es el
265
358
  // default seguro — decirle al usuario que lo atiende una persona cuando no
266
359
  // es cierto es peor que lo contrario.
@@ -287,9 +380,16 @@ function parseWidgetEvent(raw, now = Date.now) {
287
380
  // Annotate the CommonJS export names for ESM import in node:
288
381
  0 && (module.exports = {
289
382
  ALLOWED_UPLOAD_CONTENT_TYPES,
383
+ DEFAULT_SURVEY_TEXT_LEN,
290
384
  MAX_AGENT_NAME,
291
385
  MAX_QUICK_REPLIES,
292
386
  MAX_QUICK_REPLY_LABEL,
387
+ MAX_SURVEY_CHOICE_OPTIONS,
388
+ MAX_SURVEY_ID,
389
+ MAX_SURVEY_LABEL,
390
+ MAX_SURVEY_OPTION_LABEL,
391
+ MAX_SURVEY_STEPS,
392
+ MAX_SURVEY_TEXT_LEN,
293
393
  MAX_THEME_FONT_FAMILY,
294
394
  MAX_THEME_LABEL,
295
395
  MAX_THEME_TAGLINE,
@@ -310,5 +410,6 @@ function parseWidgetEvent(raw, now = Date.now) {
310
410
  safeThemeUrl,
311
411
  sanitizeAssistantIdentity,
312
412
  sanitizeQuickReplies,
313
- sanitizeServerTheme
413
+ sanitizeServerTheme,
414
+ sanitizeSurvey
314
415
  });
package/dist/index.d.cts CHANGED
@@ -194,6 +194,16 @@ interface HistoryMessageDto {
194
194
  * elegir, al volver tiene que poder elegir igual.
195
195
  */
196
196
  quick_replies?: QuickReply[];
197
+ /**
198
+ * Encuesta embebida del mensaje (`content_type: "survey"`), con su estado
199
+ * `answered` denormalizado EN el item (Red Team #4): reabrir el chat no
200
+ * re-ofrece una encuesta contestada.
201
+ *
202
+ * Mismo trato que en el evento WS: el valor crudo del wire pasa por
203
+ * `sanitizeSurvey` en el consumidor ANTES de renderizar — el tipo documenta
204
+ * el contrato, la función es la barrera.
205
+ */
206
+ survey?: SurveyPayload;
197
207
  /** Ausente ⇒ `bot`. Ver `MessageAuthorKind`. */
198
208
  author?: MessageAuthorKind;
199
209
  }
@@ -334,7 +344,7 @@ interface ChallengeUiConfig {
334
344
  interface ThemeResponse {
335
345
  /** Color de marca. `#rgb` o `#rrggbb`. Pasa por `safeThemeColor`. */
336
346
  primaryColor?: string;
337
- /** URL https del ícono del launcher. */
347
+ /** Ícono del launcher: URL https, un built-in ("bircle" | "chat") o un emoji corto. */
338
348
  launcherIcon?: string;
339
349
  position?: "bottom-right" | "bottom-left";
340
350
  /** URL https del logo del header. */
@@ -460,19 +470,6 @@ declare function safeThemeColor(value: unknown): string | null;
460
470
  * que termina en un `<img src>` es un vector de XSS en el sitio del cliente.
461
471
  */
462
472
  declare function safeThemeUrl(value: unknown): string | null;
463
- /**
464
- * Tema del server, saneado campo por campo contra el contrato.
465
- *
466
- * ES EL ÚNICO CAMINO DE ENTRADA del branding remoto: lo que no sale de acá no
467
- * debería aplicarse. Devuelve un objeto NUEVO con sólo los campos válidos —
468
- * nunca el input— así ninguna clave extra del wire (una que el server agregue
469
- * mañana, o una que un theme viejo tenga guardada) se cuela por un spread.
470
- *
471
- * NUNCA LANZA Y NUNCA RECHAZA EL TEMA ENTERO: un campo inválido se descarta y el
472
- * consumidor cae a su default. Perder el branding completo —o peor, no pintar el
473
- * chat— porque el `accentColor` está mal cargado sería mucho peor que mostrar el
474
- * acento por default.
475
- */
476
473
  declare function sanitizeServerTheme(raw: unknown): ServerThemeResponse;
477
474
  /**
478
475
  * Evento entregado por el WebSocket del widget (respuesta del asistente o del
@@ -513,6 +510,119 @@ declare const MAX_QUICK_REPLY_LABEL = 40;
513
510
  * respuesta del agente por un botón mal formado sería mucho peor.
514
511
  */
515
512
  declare function sanitizeQuickReplies(raw: unknown): QuickReply[];
513
+ /** Tope de pasos de una encuesta (límite del contrato de `surveys`). */
514
+ declare const MAX_SURVEY_STEPS = 5;
515
+ /** Tope de opciones de un paso `choice`. */
516
+ declare const MAX_SURVEY_CHOICE_OPTIONS = 10;
517
+ /** Tope del `label` de un paso. */
518
+ declare const MAX_SURVEY_LABEL = 100;
519
+ /** Tope del `label` de una opción de `choice` (entra en un chip de teléfono). */
520
+ declare const MAX_SURVEY_OPTION_LABEL = 30;
521
+ /** Tope de los ids/keys (`survey_id`, `survey_msg_id`, `key`, `option.id`). */
522
+ declare const MAX_SURVEY_ID = 64;
523
+ /** Tope duro de `max_len` de un paso `text` (mismo límite que el builder). */
524
+ declare const MAX_SURVEY_TEXT_LEN = 1000;
525
+ /** `max_len` efectivo cuando la definición no trae uno. */
526
+ declare const DEFAULT_SURVEY_TEXT_LEN = 500;
527
+ /** Paso de estrellas 1–5 (el rating principal). */
528
+ interface SurveyStepStars {
529
+ type: "stars";
530
+ /** Clave del answer en el POST de respuesta (ej. `rating`). */
531
+ key: string;
532
+ /** Pregunta a mostrar. Texto plano, nunca markup. */
533
+ label: string;
534
+ }
535
+ /** Una opción elegible de un paso `choice`. */
536
+ interface SurveyChoiceOption {
537
+ /** Id estable de la opción — es lo que se REPORTA, el label es lo que se ve. */
538
+ id: string;
539
+ label: string;
540
+ }
541
+ /** Paso de opciones (motivo de la calificación), render tipo chips. */
542
+ interface SurveyStepChoice {
543
+ type: "choice";
544
+ key: string;
545
+ label: string;
546
+ options: SurveyChoiceOption[];
547
+ }
548
+ /** Paso de texto libre (comentario). */
549
+ interface SurveyStepText {
550
+ type: "text";
551
+ key: string;
552
+ label: string;
553
+ /** Tope de caracteres del comentario. Ausente ⇒ `DEFAULT_SURVEY_TEXT_LEN`. */
554
+ max_len?: number;
555
+ }
556
+ type SurveyStep = SurveyStepStars | SurveyStepChoice | SurveyStepText;
557
+ /**
558
+ * Encuesta embebida en un mensaje del asistente. Campo hermano de
559
+ * `quick_replies` — viaja igual en el evento WS y en el DTO del historial.
560
+ */
561
+ interface SurveyPayload {
562
+ /** Id de la DEFINICIÓN (tabla `surveys`). */
563
+ survey_id: string;
564
+ /** Versión de la definición con la que se generó este mensaje. */
565
+ version: number;
566
+ /**
567
+ * Id del MENSAJE survey. Es la clave con la que se responde
568
+ * (`POST /v1/widget/survey-response`) y con la que el server deduplica: el
569
+ * `response_id` determinístico sale de acá.
570
+ */
571
+ survey_msg_id: string;
572
+ /** Pasos a renderizar, en orden. Nunca vacío (sin pasos no hay encuesta). */
573
+ steps: SurveyStep[];
574
+ /** Referencia legible del caso que se califica ("¿Cómo resolvimos tu caso #123?"). */
575
+ case_ref?: string;
576
+ /**
577
+ * `true` = ya respondida. Denormalizado EN el item del mensaje (Red Team #4)
578
+ * para que la rehidratación no re-ofrezca una encuesta contestada — la UI la
579
+ * muestra en su estado "¡Gracias!" directamente.
580
+ */
581
+ answered: boolean;
582
+ }
583
+ /**
584
+ * Encuesta USABLE del wire, o `null`.
585
+ *
586
+ * ES LA BARRERA (no el tipo): todo `survey` que llega del server —evento WS o
587
+ * DTO de historial— pasa por acá antes de tocar la UI. Valida el contrato
588
+ * completo: ids/keys no vacíos ≤ 64, ≤ 5 pasos, choice con 1..10 opciones,
589
+ * labels ≤ 100 (≤ 30 en opciones), `version` entero positivo.
590
+ *
591
+ * TODO-O-NADA A PROPÓSITO (distinto de `sanitizeQuickReplies`): una encuesta
592
+ * con un paso corrupto no es "una encuesta más corta", es un instrumento que ya
593
+ * no coincide con la definición contra la que el Gateway valida la respuesta.
594
+ * Ofrecerla garantiza un 400 DESPUÉS de que el usuario contestó — peor que no
595
+ * ofrecerla (el mensaje conserva su `content` de texto fallback). NUNCA lanza.
596
+ *
597
+ * Tolerancias deliberadas (ausencia ≠ inválido):
598
+ * - `answered` ausente o `null` ⇒ `false` (un productor viejo que no
599
+ * denormaliza el estado no debe matar la encuesta); cualquier otro no-bool
600
+ * (`"true"`) sí es inválido — adivinar el estado re-ofrecería encuestas
601
+ * contestadas.
602
+ * - `case_ref` es decorativo: no-string/vacío/gigante ⇒ se omite el campo,
603
+ * la encuesta sigue (mismo criterio que el avatar en
604
+ * `sanitizeAssistantIdentity`).
605
+ */
606
+ declare function sanitizeSurvey(raw: unknown): SurveyPayload | null;
607
+ /**
608
+ * `POST /v1/widget/survey-response` — body (auth: Bearer JWT de sesión, igual
609
+ * que `/messages`). `reason` debe ser un `option.id` de la definición y
610
+ * `comment` respetar el `max_len` — el Gateway valida contra la definición y
611
+ * responde 400 si no. 409 = ya respondida (el `response_id` determinístico hace
612
+ * el retry no-op). 404 = mensaje survey inexistente.
613
+ */
614
+ interface SurveyResponseRequest {
615
+ survey_msg_id: string;
616
+ /** 1..5 (entero). */
617
+ rating: number;
618
+ /** `option.id` del paso choice, si el usuario eligió uno. */
619
+ reason?: string;
620
+ comment?: string;
621
+ }
622
+ /** `POST /v1/widget/survey-response` — respuesta 200. */
623
+ interface SurveyResponseAck {
624
+ answered: true;
625
+ }
516
626
  /**
517
627
  * Quién escribió un mensaje del lado del asistente.
518
628
  *
@@ -558,6 +668,13 @@ interface AssistantMessageEvent {
558
668
  ts: number;
559
669
  /** Botones para responder sin escribir. Ver `QuickReply`. */
560
670
  quick_replies?: QuickReply[];
671
+ /**
672
+ * Encuesta embebida (`content_type: "survey"`). Campo hermano de
673
+ * `quick_replies`, mismo patrón: sólo aparece si el wire trajo una encuesta
674
+ * VÁLIDA (pasó por `sanitizeSurvey`); un cliente que no la renderice sigue
675
+ * mostrando el `content` de texto fallback.
676
+ */
677
+ survey?: SurveyPayload;
561
678
  /**
562
679
  * Quién lo escribió. Ausente ⇒ `bot` (todo lo que existía antes de que hubiera
563
680
  * handover es del agente de IA).
@@ -582,12 +699,15 @@ interface AssistantMessageEvent {
582
699
  * - `caption` `| null`: mismo motivo que `ts` (Python `None` ⇒ `null`).
583
700
  * - `turn_ids` como `readonly unknown[]`: el guard solo verifica que sea un
584
701
  * array; los elementos los filtra `normalizeAssistantMessageEvent`.
702
+ * - `survey` como `unknown`: mismo tratamiento que `quick_replies` — el guard
703
+ * no lo mira y `sanitizeSurvey` decide en la normalización.
585
704
  */
586
- type RawAssistantMessageEvent = Omit<AssistantMessageEvent, "ts" | "turn_ids" | "content_type" | "caption"> & {
705
+ type RawAssistantMessageEvent = Omit<AssistantMessageEvent, "ts" | "turn_ids" | "content_type" | "caption" | "survey"> & {
587
706
  ts?: number | null;
588
707
  content_type?: string | null;
589
708
  caption?: string | null;
590
709
  turn_ids: readonly unknown[];
710
+ survey?: unknown;
591
711
  };
592
712
  type WidgetEvent = AssistantMessageEvent;
593
713
  /** Union cruda del wire (hoy un solo tipo de evento; `typing`/etc. se sumarían acá). */
@@ -678,4 +798,4 @@ declare function normalizeAssistantMessageEvent(raw: RawAssistantMessageEvent, n
678
798
  */
679
799
  declare function parseWidgetEvent(raw: string, now?: () => number): WidgetEvent | null;
680
800
 
681
- 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 };
801
+ export { ALLOWED_UPLOAD_CONTENT_TYPES, type AssistantIdentity, type AssistantMessageEvent, type ChallengeUiConfig, DEFAULT_SURVEY_TEXT_LEN, type HistoryMessageDto, type HistoryResponse, MAX_AGENT_NAME, MAX_QUICK_REPLIES, MAX_QUICK_REPLY_LABEL, MAX_SURVEY_CHOICE_OPTIONS, MAX_SURVEY_ID, MAX_SURVEY_LABEL, MAX_SURVEY_OPTION_LABEL, MAX_SURVEY_STEPS, MAX_SURVEY_TEXT_LEN, 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 SurveyChoiceOption, type SurveyPayload, type SurveyResponseAck, type SurveyResponseRequest, type SurveyStep, type SurveyStepChoice, type SurveyStepStars, type SurveyStepText, type ThemeResponse, type UploadRequest, type UploadResponse, type WidgetEvent, isAllowedUploadContentType, isAssistantMessageEvent, isMediaContentType, isRawAssistantMessageEvent, mediaKindForContentType, normalizeAssistantMessageEvent, parseWidgetEvent, safeThemeColor, safeThemeUrl, sanitizeAssistantIdentity, sanitizeQuickReplies, sanitizeServerTheme, sanitizeSurvey };
package/dist/index.d.ts CHANGED
@@ -194,6 +194,16 @@ interface HistoryMessageDto {
194
194
  * elegir, al volver tiene que poder elegir igual.
195
195
  */
196
196
  quick_replies?: QuickReply[];
197
+ /**
198
+ * Encuesta embebida del mensaje (`content_type: "survey"`), con su estado
199
+ * `answered` denormalizado EN el item (Red Team #4): reabrir el chat no
200
+ * re-ofrece una encuesta contestada.
201
+ *
202
+ * Mismo trato que en el evento WS: el valor crudo del wire pasa por
203
+ * `sanitizeSurvey` en el consumidor ANTES de renderizar — el tipo documenta
204
+ * el contrato, la función es la barrera.
205
+ */
206
+ survey?: SurveyPayload;
197
207
  /** Ausente ⇒ `bot`. Ver `MessageAuthorKind`. */
198
208
  author?: MessageAuthorKind;
199
209
  }
@@ -334,7 +344,7 @@ interface ChallengeUiConfig {
334
344
  interface ThemeResponse {
335
345
  /** Color de marca. `#rgb` o `#rrggbb`. Pasa por `safeThemeColor`. */
336
346
  primaryColor?: string;
337
- /** URL https del ícono del launcher. */
347
+ /** Ícono del launcher: URL https, un built-in ("bircle" | "chat") o un emoji corto. */
338
348
  launcherIcon?: string;
339
349
  position?: "bottom-right" | "bottom-left";
340
350
  /** URL https del logo del header. */
@@ -460,19 +470,6 @@ declare function safeThemeColor(value: unknown): string | null;
460
470
  * que termina en un `<img src>` es un vector de XSS en el sitio del cliente.
461
471
  */
462
472
  declare function safeThemeUrl(value: unknown): string | null;
463
- /**
464
- * Tema del server, saneado campo por campo contra el contrato.
465
- *
466
- * ES EL ÚNICO CAMINO DE ENTRADA del branding remoto: lo que no sale de acá no
467
- * debería aplicarse. Devuelve un objeto NUEVO con sólo los campos válidos —
468
- * nunca el input— así ninguna clave extra del wire (una que el server agregue
469
- * mañana, o una que un theme viejo tenga guardada) se cuela por un spread.
470
- *
471
- * NUNCA LANZA Y NUNCA RECHAZA EL TEMA ENTERO: un campo inválido se descarta y el
472
- * consumidor cae a su default. Perder el branding completo —o peor, no pintar el
473
- * chat— porque el `accentColor` está mal cargado sería mucho peor que mostrar el
474
- * acento por default.
475
- */
476
473
  declare function sanitizeServerTheme(raw: unknown): ServerThemeResponse;
477
474
  /**
478
475
  * Evento entregado por el WebSocket del widget (respuesta del asistente o del
@@ -513,6 +510,119 @@ declare const MAX_QUICK_REPLY_LABEL = 40;
513
510
  * respuesta del agente por un botón mal formado sería mucho peor.
514
511
  */
515
512
  declare function sanitizeQuickReplies(raw: unknown): QuickReply[];
513
+ /** Tope de pasos de una encuesta (límite del contrato de `surveys`). */
514
+ declare const MAX_SURVEY_STEPS = 5;
515
+ /** Tope de opciones de un paso `choice`. */
516
+ declare const MAX_SURVEY_CHOICE_OPTIONS = 10;
517
+ /** Tope del `label` de un paso. */
518
+ declare const MAX_SURVEY_LABEL = 100;
519
+ /** Tope del `label` de una opción de `choice` (entra en un chip de teléfono). */
520
+ declare const MAX_SURVEY_OPTION_LABEL = 30;
521
+ /** Tope de los ids/keys (`survey_id`, `survey_msg_id`, `key`, `option.id`). */
522
+ declare const MAX_SURVEY_ID = 64;
523
+ /** Tope duro de `max_len` de un paso `text` (mismo límite que el builder). */
524
+ declare const MAX_SURVEY_TEXT_LEN = 1000;
525
+ /** `max_len` efectivo cuando la definición no trae uno. */
526
+ declare const DEFAULT_SURVEY_TEXT_LEN = 500;
527
+ /** Paso de estrellas 1–5 (el rating principal). */
528
+ interface SurveyStepStars {
529
+ type: "stars";
530
+ /** Clave del answer en el POST de respuesta (ej. `rating`). */
531
+ key: string;
532
+ /** Pregunta a mostrar. Texto plano, nunca markup. */
533
+ label: string;
534
+ }
535
+ /** Una opción elegible de un paso `choice`. */
536
+ interface SurveyChoiceOption {
537
+ /** Id estable de la opción — es lo que se REPORTA, el label es lo que se ve. */
538
+ id: string;
539
+ label: string;
540
+ }
541
+ /** Paso de opciones (motivo de la calificación), render tipo chips. */
542
+ interface SurveyStepChoice {
543
+ type: "choice";
544
+ key: string;
545
+ label: string;
546
+ options: SurveyChoiceOption[];
547
+ }
548
+ /** Paso de texto libre (comentario). */
549
+ interface SurveyStepText {
550
+ type: "text";
551
+ key: string;
552
+ label: string;
553
+ /** Tope de caracteres del comentario. Ausente ⇒ `DEFAULT_SURVEY_TEXT_LEN`. */
554
+ max_len?: number;
555
+ }
556
+ type SurveyStep = SurveyStepStars | SurveyStepChoice | SurveyStepText;
557
+ /**
558
+ * Encuesta embebida en un mensaje del asistente. Campo hermano de
559
+ * `quick_replies` — viaja igual en el evento WS y en el DTO del historial.
560
+ */
561
+ interface SurveyPayload {
562
+ /** Id de la DEFINICIÓN (tabla `surveys`). */
563
+ survey_id: string;
564
+ /** Versión de la definición con la que se generó este mensaje. */
565
+ version: number;
566
+ /**
567
+ * Id del MENSAJE survey. Es la clave con la que se responde
568
+ * (`POST /v1/widget/survey-response`) y con la que el server deduplica: el
569
+ * `response_id` determinístico sale de acá.
570
+ */
571
+ survey_msg_id: string;
572
+ /** Pasos a renderizar, en orden. Nunca vacío (sin pasos no hay encuesta). */
573
+ steps: SurveyStep[];
574
+ /** Referencia legible del caso que se califica ("¿Cómo resolvimos tu caso #123?"). */
575
+ case_ref?: string;
576
+ /**
577
+ * `true` = ya respondida. Denormalizado EN el item del mensaje (Red Team #4)
578
+ * para que la rehidratación no re-ofrezca una encuesta contestada — la UI la
579
+ * muestra en su estado "¡Gracias!" directamente.
580
+ */
581
+ answered: boolean;
582
+ }
583
+ /**
584
+ * Encuesta USABLE del wire, o `null`.
585
+ *
586
+ * ES LA BARRERA (no el tipo): todo `survey` que llega del server —evento WS o
587
+ * DTO de historial— pasa por acá antes de tocar la UI. Valida el contrato
588
+ * completo: ids/keys no vacíos ≤ 64, ≤ 5 pasos, choice con 1..10 opciones,
589
+ * labels ≤ 100 (≤ 30 en opciones), `version` entero positivo.
590
+ *
591
+ * TODO-O-NADA A PROPÓSITO (distinto de `sanitizeQuickReplies`): una encuesta
592
+ * con un paso corrupto no es "una encuesta más corta", es un instrumento que ya
593
+ * no coincide con la definición contra la que el Gateway valida la respuesta.
594
+ * Ofrecerla garantiza un 400 DESPUÉS de que el usuario contestó — peor que no
595
+ * ofrecerla (el mensaje conserva su `content` de texto fallback). NUNCA lanza.
596
+ *
597
+ * Tolerancias deliberadas (ausencia ≠ inválido):
598
+ * - `answered` ausente o `null` ⇒ `false` (un productor viejo que no
599
+ * denormaliza el estado no debe matar la encuesta); cualquier otro no-bool
600
+ * (`"true"`) sí es inválido — adivinar el estado re-ofrecería encuestas
601
+ * contestadas.
602
+ * - `case_ref` es decorativo: no-string/vacío/gigante ⇒ se omite el campo,
603
+ * la encuesta sigue (mismo criterio que el avatar en
604
+ * `sanitizeAssistantIdentity`).
605
+ */
606
+ declare function sanitizeSurvey(raw: unknown): SurveyPayload | null;
607
+ /**
608
+ * `POST /v1/widget/survey-response` — body (auth: Bearer JWT de sesión, igual
609
+ * que `/messages`). `reason` debe ser un `option.id` de la definición y
610
+ * `comment` respetar el `max_len` — el Gateway valida contra la definición y
611
+ * responde 400 si no. 409 = ya respondida (el `response_id` determinístico hace
612
+ * el retry no-op). 404 = mensaje survey inexistente.
613
+ */
614
+ interface SurveyResponseRequest {
615
+ survey_msg_id: string;
616
+ /** 1..5 (entero). */
617
+ rating: number;
618
+ /** `option.id` del paso choice, si el usuario eligió uno. */
619
+ reason?: string;
620
+ comment?: string;
621
+ }
622
+ /** `POST /v1/widget/survey-response` — respuesta 200. */
623
+ interface SurveyResponseAck {
624
+ answered: true;
625
+ }
516
626
  /**
517
627
  * Quién escribió un mensaje del lado del asistente.
518
628
  *
@@ -558,6 +668,13 @@ interface AssistantMessageEvent {
558
668
  ts: number;
559
669
  /** Botones para responder sin escribir. Ver `QuickReply`. */
560
670
  quick_replies?: QuickReply[];
671
+ /**
672
+ * Encuesta embebida (`content_type: "survey"`). Campo hermano de
673
+ * `quick_replies`, mismo patrón: sólo aparece si el wire trajo una encuesta
674
+ * VÁLIDA (pasó por `sanitizeSurvey`); un cliente que no la renderice sigue
675
+ * mostrando el `content` de texto fallback.
676
+ */
677
+ survey?: SurveyPayload;
561
678
  /**
562
679
  * Quién lo escribió. Ausente ⇒ `bot` (todo lo que existía antes de que hubiera
563
680
  * handover es del agente de IA).
@@ -582,12 +699,15 @@ interface AssistantMessageEvent {
582
699
  * - `caption` `| null`: mismo motivo que `ts` (Python `None` ⇒ `null`).
583
700
  * - `turn_ids` como `readonly unknown[]`: el guard solo verifica que sea un
584
701
  * array; los elementos los filtra `normalizeAssistantMessageEvent`.
702
+ * - `survey` como `unknown`: mismo tratamiento que `quick_replies` — el guard
703
+ * no lo mira y `sanitizeSurvey` decide en la normalización.
585
704
  */
586
- type RawAssistantMessageEvent = Omit<AssistantMessageEvent, "ts" | "turn_ids" | "content_type" | "caption"> & {
705
+ type RawAssistantMessageEvent = Omit<AssistantMessageEvent, "ts" | "turn_ids" | "content_type" | "caption" | "survey"> & {
587
706
  ts?: number | null;
588
707
  content_type?: string | null;
589
708
  caption?: string | null;
590
709
  turn_ids: readonly unknown[];
710
+ survey?: unknown;
591
711
  };
592
712
  type WidgetEvent = AssistantMessageEvent;
593
713
  /** Union cruda del wire (hoy un solo tipo de evento; `typing`/etc. se sumarían acá). */
@@ -678,4 +798,4 @@ declare function normalizeAssistantMessageEvent(raw: RawAssistantMessageEvent, n
678
798
  */
679
799
  declare function parseWidgetEvent(raw: string, now?: () => number): WidgetEvent | null;
680
800
 
681
- 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 };
801
+ export { ALLOWED_UPLOAD_CONTENT_TYPES, type AssistantIdentity, type AssistantMessageEvent, type ChallengeUiConfig, DEFAULT_SURVEY_TEXT_LEN, type HistoryMessageDto, type HistoryResponse, MAX_AGENT_NAME, MAX_QUICK_REPLIES, MAX_QUICK_REPLY_LABEL, MAX_SURVEY_CHOICE_OPTIONS, MAX_SURVEY_ID, MAX_SURVEY_LABEL, MAX_SURVEY_OPTION_LABEL, MAX_SURVEY_STEPS, MAX_SURVEY_TEXT_LEN, 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 SurveyChoiceOption, type SurveyPayload, type SurveyResponseAck, type SurveyResponseRequest, type SurveyStep, type SurveyStepChoice, type SurveyStepStars, type SurveyStepText, type ThemeResponse, type UploadRequest, type UploadResponse, type WidgetEvent, isAllowedUploadContentType, isAssistantMessageEvent, isMediaContentType, isRawAssistantMessageEvent, mediaKindForContentType, normalizeAssistantMessageEvent, parseWidgetEvent, safeThemeColor, safeThemeUrl, sanitizeAssistantIdentity, sanitizeQuickReplies, sanitizeServerTheme, sanitizeSurvey };
package/dist/index.js CHANGED
@@ -83,6 +83,13 @@ function sanitizeThemeTexts(raw) {
83
83
  }
84
84
  return n > 0 ? out : null;
85
85
  }
86
+ function safeLauncherToken(v) {
87
+ if (typeof v !== "string") return null;
88
+ const t = v.trim();
89
+ if (t === "bircle" || t === "chat") return t;
90
+ if (t.length > 0 && t.length <= 8 && !t.includes(":") && !t.includes("/")) return t;
91
+ return null;
92
+ }
86
93
  function sanitizeServerTheme(raw) {
87
94
  if (typeof raw !== "object" || raw === null) return {};
88
95
  const o = raw;
@@ -91,7 +98,7 @@ function sanitizeServerTheme(raw) {
91
98
  if (primaryColor !== null) out.primaryColor = primaryColor;
92
99
  const accentColor = safeThemeColor(o.accentColor);
93
100
  if (accentColor !== null) out.accentColor = accentColor;
94
- const launcherIcon = safeThemeUrl(o.launcherIcon);
101
+ const launcherIcon = safeThemeUrl(o.launcherIcon) ?? safeLauncherToken(o.launcherIcon);
95
102
  if (launcherIcon !== null) out.launcherIcon = launcherIcon;
96
103
  const headerLogo = safeThemeUrl(o.headerLogo);
97
104
  if (headerLogo !== null) out.headerLogo = headerLogo;
@@ -155,6 +162,76 @@ function sanitizeQuickReplies(raw) {
155
162
  }
156
163
  return out;
157
164
  }
165
+ var MAX_SURVEY_STEPS = 5;
166
+ var MAX_SURVEY_CHOICE_OPTIONS = 10;
167
+ var MAX_SURVEY_LABEL = 100;
168
+ var MAX_SURVEY_OPTION_LABEL = 30;
169
+ var MAX_SURVEY_ID = 64;
170
+ var MAX_SURVEY_TEXT_LEN = 1e3;
171
+ var DEFAULT_SURVEY_TEXT_LEN = 500;
172
+ function surveyId(value, max = MAX_SURVEY_ID) {
173
+ if (typeof value !== "string") return null;
174
+ const v = value.trim();
175
+ return v !== "" && v.length <= max ? v : null;
176
+ }
177
+ function sanitizeSurveyStep(raw) {
178
+ if (typeof raw !== "object" || raw === null) return null;
179
+ const o = raw;
180
+ const key = surveyId(o.key);
181
+ const label = surveyId(o.label, MAX_SURVEY_LABEL);
182
+ if (key === null || label === null) return null;
183
+ switch (o.type) {
184
+ case "stars":
185
+ return { type: "stars", key, label };
186
+ case "choice": {
187
+ if (!Array.isArray(o.options)) return null;
188
+ if (o.options.length === 0 || o.options.length > MAX_SURVEY_CHOICE_OPTIONS) return null;
189
+ const options = [];
190
+ for (const item of o.options) {
191
+ if (typeof item !== "object" || item === null) return null;
192
+ const opt = item;
193
+ const id = surveyId(opt.id);
194
+ const optLabel = surveyId(opt.label, MAX_SURVEY_OPTION_LABEL);
195
+ if (id === null || optLabel === null) return null;
196
+ options.push({ id, label: optLabel });
197
+ }
198
+ return { type: "choice", key, label, options };
199
+ }
200
+ case "text": {
201
+ const step = { type: "text", key, label };
202
+ if (typeof o.max_len === "number" && Number.isInteger(o.max_len) && o.max_len > 0 && o.max_len <= MAX_SURVEY_TEXT_LEN) {
203
+ step.max_len = o.max_len;
204
+ }
205
+ return step;
206
+ }
207
+ default:
208
+ return null;
209
+ }
210
+ }
211
+ function sanitizeSurvey(raw) {
212
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
213
+ const o = raw;
214
+ const survey_id = surveyId(o.survey_id);
215
+ const survey_msg_id = surveyId(o.survey_msg_id);
216
+ if (survey_id === null || survey_msg_id === null) return null;
217
+ if (typeof o.version !== "number" || !Number.isInteger(o.version) || o.version <= 0) return null;
218
+ if (!Array.isArray(o.steps)) return null;
219
+ if (o.steps.length === 0 || o.steps.length > MAX_SURVEY_STEPS) return null;
220
+ const steps = [];
221
+ for (const rawStep of o.steps) {
222
+ const step = sanitizeSurveyStep(rawStep);
223
+ if (step === null) return null;
224
+ steps.push(step);
225
+ }
226
+ let answered;
227
+ if (o.answered === void 0 || o.answered === null) answered = false;
228
+ else if (typeof o.answered === "boolean") answered = o.answered;
229
+ else return null;
230
+ const out = { survey_id, version: o.version, survey_msg_id, steps, answered };
231
+ const case_ref = surveyId(o.case_ref, MAX_SURVEY_LABEL);
232
+ if (case_ref !== null) out.case_ref = case_ref;
233
+ return out;
234
+ }
158
235
  var MAX_AGENT_NAME = 40;
159
236
  function sanitizeAssistantIdentity(raw, kind = "human") {
160
237
  if (typeof raw !== "object" || raw === null) return null;
@@ -195,6 +272,7 @@ function isAssistantMessageEvent(v) {
195
272
  if (v.caption !== void 0 && typeof v.caption !== "string") return false;
196
273
  if (v.content_type !== void 0 && v.content_type !== "text" && !isMediaContentType(v.content_type))
197
274
  return false;
275
+ if (v.survey !== void 0 && sanitizeSurvey(v.survey) === null) return false;
198
276
  return v.turn_ids.every((t) => typeof t === "string");
199
277
  }
200
278
  function normalizeAssistantMessageEvent(raw, now = Date.now) {
@@ -213,6 +291,13 @@ function normalizeAssistantMessageEvent(raw, now = Date.now) {
213
291
  const qr = sanitizeQuickReplies(raw.quick_replies);
214
292
  return qr.length > 0 ? { quick_replies: qr } : {};
215
293
  })(),
294
+ // La FUNCIÓN es la barrera (lección FAQs): un `survey` que no pasa el saneo
295
+ // no viaja — el mensaje conserva su `content` de texto fallback, así que
296
+ // una encuesta corrupta nunca cuesta la respuesta del agente.
297
+ ...(() => {
298
+ const sv = sanitizeSurvey(raw.survey);
299
+ return sv !== null ? { survey: sv } : {};
300
+ })(),
216
301
  // Cualquier valor que no sea exactamente "human" cuenta como bot: es el
217
302
  // default seguro — decirle al usuario que lo atiende una persona cuando no
218
303
  // es cierto es peor que lo contrario.
@@ -238,9 +323,16 @@ function parseWidgetEvent(raw, now = Date.now) {
238
323
  }
239
324
  export {
240
325
  ALLOWED_UPLOAD_CONTENT_TYPES,
326
+ DEFAULT_SURVEY_TEXT_LEN,
241
327
  MAX_AGENT_NAME,
242
328
  MAX_QUICK_REPLIES,
243
329
  MAX_QUICK_REPLY_LABEL,
330
+ MAX_SURVEY_CHOICE_OPTIONS,
331
+ MAX_SURVEY_ID,
332
+ MAX_SURVEY_LABEL,
333
+ MAX_SURVEY_OPTION_LABEL,
334
+ MAX_SURVEY_STEPS,
335
+ MAX_SURVEY_TEXT_LEN,
244
336
  MAX_THEME_FONT_FAMILY,
245
337
  MAX_THEME_LABEL,
246
338
  MAX_THEME_TAGLINE,
@@ -261,5 +353,6 @@ export {
261
353
  safeThemeUrl,
262
354
  sanitizeAssistantIdentity,
263
355
  sanitizeQuickReplies,
264
- sanitizeServerTheme
356
+ sanitizeServerTheme,
357
+ sanitizeSurvey
265
358
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bircleai/widget-protocol",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Contrato compartido del Chat Gateway de BircleAI (tipos + helpers de parseo).",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "type": "module",