@bircleai/widget-protocol 0.6.2 → 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 = [
@@ -210,6 +218,76 @@ function sanitizeQuickReplies(raw) {
210
218
  }
211
219
  return out;
212
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
+ }
213
291
  var MAX_AGENT_NAME = 40;
214
292
  function sanitizeAssistantIdentity(raw, kind = "human") {
215
293
  if (typeof raw !== "object" || raw === null) return null;
@@ -250,6 +328,7 @@ function isAssistantMessageEvent(v) {
250
328
  if (v.caption !== void 0 && typeof v.caption !== "string") return false;
251
329
  if (v.content_type !== void 0 && v.content_type !== "text" && !isMediaContentType(v.content_type))
252
330
  return false;
331
+ if (v.survey !== void 0 && sanitizeSurvey(v.survey) === null) return false;
253
332
  return v.turn_ids.every((t) => typeof t === "string");
254
333
  }
255
334
  function normalizeAssistantMessageEvent(raw, now = Date.now) {
@@ -268,6 +347,13 @@ function normalizeAssistantMessageEvent(raw, now = Date.now) {
268
347
  const qr = sanitizeQuickReplies(raw.quick_replies);
269
348
  return qr.length > 0 ? { quick_replies: qr } : {};
270
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
+ })(),
271
357
  // Cualquier valor que no sea exactamente "human" cuenta como bot: es el
272
358
  // default seguro — decirle al usuario que lo atiende una persona cuando no
273
359
  // es cierto es peor que lo contrario.
@@ -294,9 +380,16 @@ function parseWidgetEvent(raw, now = Date.now) {
294
380
  // Annotate the CommonJS export names for ESM import in node:
295
381
  0 && (module.exports = {
296
382
  ALLOWED_UPLOAD_CONTENT_TYPES,
383
+ DEFAULT_SURVEY_TEXT_LEN,
297
384
  MAX_AGENT_NAME,
298
385
  MAX_QUICK_REPLIES,
299
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,
300
393
  MAX_THEME_FONT_FAMILY,
301
394
  MAX_THEME_LABEL,
302
395
  MAX_THEME_TAGLINE,
@@ -317,5 +410,6 @@ function parseWidgetEvent(raw, now = Date.now) {
317
410
  safeThemeUrl,
318
411
  sanitizeAssistantIdentity,
319
412
  sanitizeQuickReplies,
320
- sanitizeServerTheme
413
+ sanitizeServerTheme,
414
+ sanitizeSurvey
321
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
  }
@@ -500,6 +510,119 @@ declare const MAX_QUICK_REPLY_LABEL = 40;
500
510
  * respuesta del agente por un botón mal formado sería mucho peor.
501
511
  */
502
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
+ }
503
626
  /**
504
627
  * Quién escribió un mensaje del lado del asistente.
505
628
  *
@@ -545,6 +668,13 @@ interface AssistantMessageEvent {
545
668
  ts: number;
546
669
  /** Botones para responder sin escribir. Ver `QuickReply`. */
547
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;
548
678
  /**
549
679
  * Quién lo escribió. Ausente ⇒ `bot` (todo lo que existía antes de que hubiera
550
680
  * handover es del agente de IA).
@@ -569,12 +699,15 @@ interface AssistantMessageEvent {
569
699
  * - `caption` `| null`: mismo motivo que `ts` (Python `None` ⇒ `null`).
570
700
  * - `turn_ids` como `readonly unknown[]`: el guard solo verifica que sea un
571
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.
572
704
  */
573
- type RawAssistantMessageEvent = Omit<AssistantMessageEvent, "ts" | "turn_ids" | "content_type" | "caption"> & {
705
+ type RawAssistantMessageEvent = Omit<AssistantMessageEvent, "ts" | "turn_ids" | "content_type" | "caption" | "survey"> & {
574
706
  ts?: number | null;
575
707
  content_type?: string | null;
576
708
  caption?: string | null;
577
709
  turn_ids: readonly unknown[];
710
+ survey?: unknown;
578
711
  };
579
712
  type WidgetEvent = AssistantMessageEvent;
580
713
  /** Union cruda del wire (hoy un solo tipo de evento; `typing`/etc. se sumarían acá). */
@@ -665,4 +798,4 @@ declare function normalizeAssistantMessageEvent(raw: RawAssistantMessageEvent, n
665
798
  */
666
799
  declare function parseWidgetEvent(raw: string, now?: () => number): WidgetEvent | null;
667
800
 
668
- 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
  }
@@ -500,6 +510,119 @@ declare const MAX_QUICK_REPLY_LABEL = 40;
500
510
  * respuesta del agente por un botón mal formado sería mucho peor.
501
511
  */
502
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
+ }
503
626
  /**
504
627
  * Quién escribió un mensaje del lado del asistente.
505
628
  *
@@ -545,6 +668,13 @@ interface AssistantMessageEvent {
545
668
  ts: number;
546
669
  /** Botones para responder sin escribir. Ver `QuickReply`. */
547
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;
548
678
  /**
549
679
  * Quién lo escribió. Ausente ⇒ `bot` (todo lo que existía antes de que hubiera
550
680
  * handover es del agente de IA).
@@ -569,12 +699,15 @@ interface AssistantMessageEvent {
569
699
  * - `caption` `| null`: mismo motivo que `ts` (Python `None` ⇒ `null`).
570
700
  * - `turn_ids` como `readonly unknown[]`: el guard solo verifica que sea un
571
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.
572
704
  */
573
- type RawAssistantMessageEvent = Omit<AssistantMessageEvent, "ts" | "turn_ids" | "content_type" | "caption"> & {
705
+ type RawAssistantMessageEvent = Omit<AssistantMessageEvent, "ts" | "turn_ids" | "content_type" | "caption" | "survey"> & {
574
706
  ts?: number | null;
575
707
  content_type?: string | null;
576
708
  caption?: string | null;
577
709
  turn_ids: readonly unknown[];
710
+ survey?: unknown;
578
711
  };
579
712
  type WidgetEvent = AssistantMessageEvent;
580
713
  /** Union cruda del wire (hoy un solo tipo de evento; `typing`/etc. se sumarían acá). */
@@ -665,4 +798,4 @@ declare function normalizeAssistantMessageEvent(raw: RawAssistantMessageEvent, n
665
798
  */
666
799
  declare function parseWidgetEvent(raw: string, now?: () => number): WidgetEvent | null;
667
800
 
668
- 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
@@ -162,6 +162,76 @@ function sanitizeQuickReplies(raw) {
162
162
  }
163
163
  return out;
164
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
+ }
165
235
  var MAX_AGENT_NAME = 40;
166
236
  function sanitizeAssistantIdentity(raw, kind = "human") {
167
237
  if (typeof raw !== "object" || raw === null) return null;
@@ -202,6 +272,7 @@ function isAssistantMessageEvent(v) {
202
272
  if (v.caption !== void 0 && typeof v.caption !== "string") return false;
203
273
  if (v.content_type !== void 0 && v.content_type !== "text" && !isMediaContentType(v.content_type))
204
274
  return false;
275
+ if (v.survey !== void 0 && sanitizeSurvey(v.survey) === null) return false;
205
276
  return v.turn_ids.every((t) => typeof t === "string");
206
277
  }
207
278
  function normalizeAssistantMessageEvent(raw, now = Date.now) {
@@ -220,6 +291,13 @@ function normalizeAssistantMessageEvent(raw, now = Date.now) {
220
291
  const qr = sanitizeQuickReplies(raw.quick_replies);
221
292
  return qr.length > 0 ? { quick_replies: qr } : {};
222
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
+ })(),
223
301
  // Cualquier valor que no sea exactamente "human" cuenta como bot: es el
224
302
  // default seguro — decirle al usuario que lo atiende una persona cuando no
225
303
  // es cierto es peor que lo contrario.
@@ -245,9 +323,16 @@ function parseWidgetEvent(raw, now = Date.now) {
245
323
  }
246
324
  export {
247
325
  ALLOWED_UPLOAD_CONTENT_TYPES,
326
+ DEFAULT_SURVEY_TEXT_LEN,
248
327
  MAX_AGENT_NAME,
249
328
  MAX_QUICK_REPLIES,
250
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,
251
336
  MAX_THEME_FONT_FAMILY,
252
337
  MAX_THEME_LABEL,
253
338
  MAX_THEME_TAGLINE,
@@ -268,5 +353,6 @@ export {
268
353
  safeThemeUrl,
269
354
  sanitizeAssistantIdentity,
270
355
  sanitizeQuickReplies,
271
- sanitizeServerTheme
356
+ sanitizeServerTheme,
357
+ sanitizeSurvey
272
358
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bircleai/widget-protocol",
3
- "version": "0.6.2",
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",