WIP: mejoras de fidelidad, editor, lógica y suite e2e (checkpoint tras reinicio)

This commit is contained in:
urieljareth
2026-09-27 20:46:51 -06:00
parent dc6e31a9d7
commit 9a5f21badf
33 changed files with 7719 additions and 1129 deletions
+212 -48
View File
@@ -116,12 +116,60 @@ window.WAM = window.WAM || {};
};
}
/* IDs entregados por generarId() que aún pueden no estar en WAM.estado
(p. ej. un clon con respuestas anidadas que se inserta al final): así dos
llamadas seguidas nunca devuelven el mismo ID. Se vacía al sustituir el
estado entero (cargar / resetear / normalizar de un import). */
var entregados = new Set();
WAM.generarId = function () {
var conjunto = new Set();
var conjunto = new Set(entregados);
recolectarIds(WAM.estado, conjunto);
return crearAlocador(conjunto)();
var id = crearAlocador(conjunto)();
entregados.add(id);
return id;
};
/* Alocador por lotes: devuelve una función tomar() que genera IDs únicos
frente a WAM.estado, a los ya entregados y a los que ella misma reparte.
`extra` (opcional): objeto/array cuyos id también se reservan. */
WAM.crearAlocador = function (extra) {
var conjunto = new Set(entregados);
recolectarIds(WAM.estado, conjunto);
if (extra) recolectarIds(extra, conjunto);
var tomar = crearAlocador(conjunto);
return function () {
var id = tomar();
entregados.add(id);
return id;
};
};
/* ————————————————— URLs seguras ————————————————— */
/* true si la URL puede ir a un href (tipo "enlace") o a un src ("media").
Enlaces: http(s), mailto, tel o sin esquema (relativa / "www…").
Media: http(s), blob, data:image|video|audio o sin esquema.
Rechaza javascript:, vbscript:, data:text/html, file:, etc. */
WAM.urlSegura = function (u, tipo) {
var s = String(u == null ? "" : u).replace(/[\u0000- \u007f-\u009f]/g, "");
if (!s) return true;
var m = /^([a-z][a-z0-9+.\-]*):/i.exec(s);
if (!m) return true;
var esquema = m[1].toLowerCase();
if (esquema === "http" || esquema === "https") return true;
if (tipo === "media") {
if (esquema === "blob") return true;
return esquema === "data" && /^data:(image|video|audio)\//i.test(s);
}
return esquema === "mailto" || esquema === "tel";
};
function urlLimpia(v, tipo) {
var s = cad(v).trim();
return WAM.urlSegura(s, tipo) ? s : "";
}
/* ————————————————— mensajes ————————————————— */
var CAMPOS = {
@@ -159,65 +207,109 @@ window.WAM = window.WAM || {};
/* ————————————————— normalización ————————————————— */
/* Rellena los campos que faltan por tipo y devuelve un estado válido.
Usado por cargar() y por editor.importarJSON (import / upgrade). */
WAM.normalizar = function (o) {
var src = (o && typeof o === "object" && !Array.isArray(o)) ? o : {};
/* Profundidad máxima de respuestas encadenadas (defensa ante JSON hostil). */
var MAX_PROFUNDIDAD = 12;
var MAX_ITEMS = 10;
var CLAVES_PROHIBIDAS = ["__proto__", "constructor", "prototype"];
var RE_ID = /^[A-Za-z0-9_-]{1,40}$/;
/* Índice que la reproducción "pulsa" sola (entero ≥ 0) o null. */
function indiceAuto(v) {
if (v === null || v === undefined || v === "" || typeof v === "boolean") return null;
var n = typeof v === "number" ? v : Number(String(v).trim());
if (!isFinite(n) || n < 0 || Math.floor(n) !== n) return null;
return n;
}
function lista(v, max) {
return (Array.isArray(v) ? v : []).slice(0, max || MAX_ITEMS);
}
/* Rellena los campos que faltan por tipo y devuelve un estado válido.
Usado por cargar(), resetear() y editor.importarJSON (import / upgrade).
- Acepta también un array de mensajes suelto como raíz.
- IDs: conserva los válidos y únicos; duplicados o inválidos se reasignan.
No reserva los IDs del WAM.estado vivo (el estado se sustituye entero).
- URLs peligrosas (javascript:, data:text/html…) se vacían.
- Respuestas en forma de cadena → mensaje de texto.
- Conserva campos extra primitivos desconocidos (compatibilidad hacia
delante con campos que añada el editor). */
WAM.normalizar = function (o) {
var src = Array.isArray(o) ? { mensajes: o }
: ((o && typeof o === "object") ? o : {});
entregados = new Set();
var conjunto = new Set();
recolectarIds(src, conjunto);
recolectarIds(WAM.estado, conjunto); /* evita colisiones con lo vivo */
var tomar = crearAlocador(conjunto);
var reclamados = new Set();
function normalizarMensaje(msj, forzarEntrada) {
var m = (msj && typeof msj === "object") ? msj : {};
function idDe(v) {
if (typeof v === "number" && isFinite(v)) v = String(v);
if (typeof v === "string" && RE_ID.test(v) && !reclamados.has(v)) {
reclamados.add(v);
return v;
}
var nuevo = tomar();
reclamados.add(nuevo);
return nuevo;
}
function normalizarMensaje(msj, forzarEntrada, profundidad) {
var m;
if (typeof msj === "string") m = { tipo: "text", texto: msj };
else m = (msj && typeof msj === "object" && !Array.isArray(msj)) ? msj : {};
var tipo = TIPOS.indexOf(m.tipo) !== -1 ? m.tipo : "text";
var msg = {
id: (typeof m.id === "string" && m.id) ? m.id : tomar(),
id: idDe(m.id),
dir: (!forzarEntrada && m.dir === "out") ? "out" : "in",
tipo: tipo,
hora: (typeof m.hora === "string" && m.hora) ? m.hora : WAM.horaAhora(),
hora: (typeof m.hora === "string" && m.hora.trim()) ? m.hora.trim().slice(0, 12) : WAM.horaAhora(),
estado: (m.estado === "delivered" || m.estado === "read") ? m.estado : "sent"
};
function respuestas(valor) {
var lista = Array.isArray(valor) ? valor : [];
return lista.map(function (r) { return normalizarMensaje(r, true); });
if (profundidad >= MAX_PROFUNDIDAD) return [];
return lista(valor, 20).map(function (r) { return normalizarMensaje(r, true, profundidad + 1); });
}
function botones(valor) {
var lista = Array.isArray(valor) ? valor : [];
return lista.map(function (b) {
var boton = (b && typeof b === "object") ? b : {};
return lista(valor).map(function (b) {
var boton = (b && typeof b === "object") ? b : { texto: typeof b === "string" ? b : "" };
var out = { texto: cad(boton.texto), respuestas: respuestas(boton.respuestas) };
/* Botón URL de plantilla (research-api §7.2): se conserva si viene */
if (typeof boton.url === "string") out.url = boton.url;
if (typeof boton.url === "string") out.url = urlLimpia(boton.url, "enlace");
return out;
});
}
function autoPulsar() {
var n = indiceAuto(m.autoPulsar);
if (n !== null) msg.autoPulsar = n;
}
switch (tipo) {
case "text":
msg.texto = cad(m.texto);
break;
case "image":
case "video":
msg.src = cad(m.src);
msg.src = urlLimpia(m.src, "media");
msg.caption = cad(m.caption);
break;
case "audio":
msg.src = cad(m.src);
msg.voz = m.voz === true;
msg.duracion = cad(m.duracion, "0:00");
msg.src = urlLimpia(m.src, "media");
msg.voz = m.voz === true || m.voz === "true";
msg.duracion = cad(m.duracion, "0:00") || "0:00";
break;
case "document":
msg.src = cad(m.src);
msg.src = urlLimpia(m.src, "media");
msg.caption = cad(m.caption);
msg.filename = cad(m.filename, "documento.pdf");
msg.filename = cad(m.filename, "documento.pdf") || "documento.pdf";
break;
case "sticker":
msg.src = cad(m.src);
msg.src = urlLimpia(m.src, "media");
break;
case "location":
msg.latitud = cad(m.latitud);
@@ -232,32 +324,26 @@ window.WAM = window.WAM || {};
msg.empresa = cad(m.empresa);
break;
case "template":
msg.headerTipo = HEADER_TIPOS.indexOf(m.headerTipo) !== -1 ? m.headerTipo : "";
msg.headerTexto = cad(m.headerTexto);
msg.headerMedia = cad(m.headerMedia);
msg.body = cad(m.body);
msg.footer = cad(m.footer);
msg.botones = botones(m.botones);
break;
case "buttons":
msg.headerTipo = HEADER_TIPOS.indexOf(m.headerTipo) !== -1 ? m.headerTipo : "";
msg.headerTexto = cad(m.headerTexto);
msg.headerMedia = cad(m.headerMedia);
msg.headerMedia = urlLimpia(m.headerMedia, "media");
msg.body = cad(m.body);
msg.footer = cad(m.footer);
msg.botones = botones(m.botones);
autoPulsar();
break;
case "list":
msg.header = cad(m.header);
msg.body = cad(m.body);
msg.footer = cad(m.footer);
msg.boton = cad(m.boton);
msg.secciones = (Array.isArray(m.secciones) ? m.secciones : []).map(function (s) {
msg.secciones = lista(m.secciones).map(function (s) {
var seccion = (s && typeof s === "object") ? s : {};
return {
titulo: cad(seccion.titulo),
filas: (Array.isArray(seccion.filas) ? seccion.filas : []).map(function (f) {
var fila = (f && typeof f === "object") ? f : {};
filas: lista(seccion.filas).map(function (f) {
var fila = (f && typeof f === "object") ? f : { titulo: typeof f === "string" ? f : "" };
return {
titulo: cad(fila.titulo),
descripcion: cad(fila.descripcion),
@@ -266,28 +352,39 @@ window.WAM = window.WAM || {};
})
};
});
autoPulsar();
break;
case "cta_url":
msg.headerMedia = cad(m.headerMedia);
msg.headerMedia = urlLimpia(m.headerMedia, "media");
msg.body = cad(m.body);
msg.footer = cad(m.footer);
msg.textoBoton = cad(m.textoBoton);
msg.url = cad(m.url);
msg.url = urlLimpia(m.url, "enlace");
break;
case "carousel":
msg.body = cad(m.body);
msg.cards = (Array.isArray(m.cards) ? m.cards : []).map(function (c) {
msg.cards = lista(m.cards).map(function (c) {
var card = (c && typeof c === "object") ? c : {};
return {
media: cad(card.media),
media: urlLimpia(card.media, "media"),
texto: cad(card.texto),
textoBoton: cad(card.textoBoton),
url: cad(card.url),
url: urlLimpia(card.url, "enlace"),
respuestas: respuestas(card.respuestas)
};
});
autoPulsar();
break;
}
/* Campos extra primitivos (p. ej. headerFilename) se conservan. */
for (var k in m) {
if (!Object.prototype.hasOwnProperty.call(m, k)) continue;
if (Object.prototype.hasOwnProperty.call(msg, k) || k === "autoPulsar") continue;
if (CLAVES_PROHIBIDAS.indexOf(k) !== -1 || k === "cita") continue;
var v = m[k];
if (typeof v === "string" || typeof v === "boolean" || (typeof v === "number" && isFinite(v))) msg[k] = v;
}
return msg;
}
@@ -297,18 +394,48 @@ window.WAM = window.WAM || {};
tema: src.tema === "oscuro" ? "oscuro" : "claro",
cuenta: {
nombre: cad(cuentaSrc.nombre, "Café Aurora"),
avatar: cad(cuentaSrc.avatar),
avatar: urlLimpia(cuentaSrc.avatar, "media"),
subtitulo: cad(cuentaSrc.subtitulo, "en línea"),
verificado: cuentaSrc.verificado === true
verificado: cuentaSrc.verificado === true || cuentaSrc.verificado === "true",
/* B: fecha "Hoy" + aviso de cifrado al inicio del chat (por defecto sí) */
avisoCifrado: !(cuentaSrc.avisoCifrado === false || cuentaSrc.avisoCifrado === "false")
},
mensajes: (Array.isArray(src.mensajes) ? src.mensajes : []).map(function (x) {
return normalizarMensaje(x, false);
mensajes: lista(src.mensajes, 500).map(function (x) {
return normalizarMensaje(x, false, 0);
})
};
};
/* ————————————————— persistencia ————————————————— */
/* Aviso de error de guardado: se emite UNA vez hasta el siguiente guardado
correcto, por el hook opcional WAM.onErrorGuardar(texto, error). */
var avisoEmitido = false;
var ultimoOk = true;
function esCuota(e) {
return !!e && (e.name === "QuotaExceededError" || e.name === "NS_ERROR_DOM_QUOTA_REACHED" ||
e.code === 22 || e.code === 1014);
}
function escribir() {
try {
window.localStorage.setItem(WAM.CLAVE, JSON.stringify(WAM.estado));
avisoEmitido = false;
ultimoOk = true;
} catch (e) {
ultimoOk = false;
if (avisoEmitido) return;
avisoEmitido = true;
var texto = esCuota(e)
? "No se pudo guardar: el almacenamiento del navegador está lleno (imágenes muy pesadas). Exporta la demo a JSON para no perder los cambios."
: "No se pudo guardar en este navegador (almacenamiento local bloqueado). Exporta la demo a JSON para no perder los cambios.";
if (typeof WAM.onErrorGuardar === "function") {
try { WAM.onErrorGuardar(texto, e); } catch (e2) { /* hook opcional */ }
}
}
}
WAM.cargar = function () {
var bruto = null;
try { bruto = window.localStorage.getItem(WAM.CLAVE); } catch (e) { bruto = null; }
@@ -316,20 +443,57 @@ window.WAM = window.WAM || {};
if (bruto) {
try { dato = JSON.parse(bruto); } catch (e) { dato = null; }
}
if (!dato) dato = WAM.datosDemo();
if (!dato || typeof dato !== "object") dato = WAM.datosDemo();
WAM.estado = null;
WAM.estado = WAM.normalizar(dato);
return WAM.estado;
};
/* Guardado con coalescencia: la primera llamada escribe al instante; las
que llegan en los 250 ms siguientes (p. ej. cada tecla del editor) se
agrupan en una sola escritura final. pagehide/visibilitychange vacían
lo pendiente para no perder nada al recargar o cerrar. */
var VENTANA = 250;
var temporizador = null;
var pendiente = false;
WAM.guardar = function () {
try {
window.localStorage.setItem(WAM.CLAVE, JSON.stringify(WAM.estado));
} catch (e) { /* sin localStorage disponible (cuota/file://): se ignora */ }
if (temporizador) { pendiente = true; return; }
escribir();
temporizador = setTimeout(function fin() {
temporizador = null;
if (pendiente) {
pendiente = false;
escribir();
temporizador = setTimeout(fin, VENTANA);
}
}, VENTANA);
};
/* Escritura inmediata de lo pendiente (true si quedó guardado). */
WAM.guardarYa = function () {
if (temporizador) { clearTimeout(temporizador); temporizador = null; }
pendiente = false;
escribir();
return ultimoOk;
};
function vaciarPendiente() { if (pendiente) WAM.guardarYa(); }
if (typeof window !== "undefined" && window.addEventListener) {
window.addEventListener("pagehide", vaciarPendiente);
window.addEventListener("beforeunload", vaciarPendiente);
document.addEventListener("visibilitychange", function () {
if (document.visibilityState === "hidden") vaciarPendiente();
});
}
WAM.resetear = function () {
if (temporizador) { clearTimeout(temporizador); temporizador = null; }
pendiente = false;
try { window.localStorage.removeItem(WAM.CLAVE); } catch (e) { /* idem */ }
WAM.estado = null; /* los IDs de la demo vuelven a empezar en m1 */
WAM.estado = WAM.normalizar(WAM.datosDemo());
avisoEmitido = false;
};
/* ————————————————— demo por defecto: "Café Aurora" ————————————————— */