From 08f65ca7cd0defef2d29cc1a8bd9e372c25b04d1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Oct 2026 18:39:41 +0000 Subject: [PATCH 1/2] fix(panel): horas en la zona del negocio y fotos en el hilo El panel formateaba con la zona compilada del member y el hilo escapaba el marcador de la foto como texto. BOT_TIMEZONE manda en las fechas, y [[media: id]] / [IMAGE_URL:] se pintan como imagen. Co-authored-by: santmun --- src/admin/format.ts | 36 ++++++++++++++++++++++++++------ src/admin/messageHtml.ts | 33 +++++++++++++++++++++++++++++ src/admin/views/conversations.ts | 7 ++++--- src/admin/views/leads.ts | 4 ++-- src/admin/views/tickets.ts | 2 +- test/admin/format.test.ts | 29 +++++++++++++++++++++++++ test/admin/inbox.test.ts | 16 ++++++++++++++ test/admin/messageHtml.test.ts | 26 +++++++++++++++++++++++ 8 files changed, 141 insertions(+), 12 deletions(-) create mode 100644 src/admin/messageHtml.ts create mode 100644 test/admin/format.test.ts create mode 100644 test/admin/messageHtml.test.ts diff --git a/src/admin/format.ts b/src/admin/format.ts index 720a6d77..56a51314 100644 --- a/src/admin/format.ts +++ b/src/admin/format.ts @@ -2,23 +2,47 @@ import { memberConfig } from "../../member/config.local"; // El Worker corre con reloj de sistema en UTC, así que `toLocaleString` / // `toLocaleDateString` SIN `timeZone` explícito renderizan cada fecha del panel -// en UTC — un miembro en America/Costa_Rica vería las horas 6h adelantadas. -// Este helper ancla TODAS las fechas del panel a la zona horaria del negocio, -// declarada en member/config.local.ts (el dato ya existía; el panel no lo usaba). -const BUSINESS_TZ = memberConfig.timezone || "America/Mexico_City"; +// en UTC — en México (UTC−6) las horas salen 6 h adelantadas. +// La zona sale de BOT_TIMEZONE (wrangler) y, si no está, de member/config.local.ts. + +const FALLBACK_TZ = "America/Mexico_City"; + +function isIanaTimeZone(tz: string): boolean { + try { + Intl.DateTimeFormat("en-US", { timeZone: tz }); + return true; + } catch { + return false; + } +} + +/** Zona IANA del panel. BOT_TIMEZONE gana; si falta o es inválida, la del negocio. */ +export function panelTimeZone(env?: { BOT_TIMEZONE?: string } | null): string { + const fromEnv = (env?.BOT_TIMEZONE ?? "").trim(); + if (fromEnv && isIanaTimeZone(fromEnv)) return fromEnv; + const fromMember = (memberConfig.timezone ?? "").trim(); + if (fromMember && isIanaTimeZone(fromMember)) return fromMember; + return FALLBACK_TZ; +} /** Fecha + hora en la zona del negocio (reemplaza `new Date(x).toLocaleString("es-MX")`). */ export function fmtDateTime( ts: number | string | Date, opts: Intl.DateTimeFormatOptions = {}, + env?: { BOT_TIMEZONE?: string } | null, ): string { - return new Date(ts).toLocaleString("es-MX", { timeZone: BUSINESS_TZ, ...opts }); + const { timeZone: override, ...rest } = opts; + const timeZone = (typeof override === "string" && override) || panelTimeZone(env); + return new Date(ts).toLocaleString("es-MX", { ...rest, timeZone }); } /** Solo fecha en la zona del negocio (reemplaza `new Date(x).toLocaleDateString("es-MX")`). */ export function fmtDate( ts: number | string | Date, opts: Intl.DateTimeFormatOptions = {}, + env?: { BOT_TIMEZONE?: string } | null, ): string { - return new Date(ts).toLocaleDateString("es-MX", { timeZone: BUSINESS_TZ, ...opts }); + const { timeZone: override, ...rest } = opts; + const timeZone = (typeof override === "string" && override) || panelTimeZone(env); + return new Date(ts).toLocaleDateString("es-MX", { ...rest, timeZone }); } diff --git a/src/admin/messageHtml.ts b/src/admin/messageHtml.ts new file mode 100644 index 00000000..e69b8191 --- /dev/null +++ b/src/admin/messageHtml.ts @@ -0,0 +1,33 @@ +/** + * Texto de un mensaje del hilo, listo para meter en HTML. + * + * El bot guarda la foto dentro del texto (`[IMAGE_URL: https://…]` en este + * repo, o `[[media: img_xxx]]` cuando viene de la galería). Sin este paso el + * panel enseña el marcador tal cual, aunque al cliente sí le haya llegado la foto. + */ + +function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]!)); +} + +const MARKER_RE = /\[\[media:\s*([A-Za-z0-9_-]{1,80})\]\]|\[IMAGE_URL:\s*(https?:\/\/[^\s\]]+)\]/g; + +function imgTag(src: string): string { + return `imagen`; +} + +export function renderMessageHtml(content: string): string { + let html = ""; + let last = 0; + for (const match of content.matchAll(MARKER_RE)) { + const index = match.index ?? 0; + html += escapeHtml(content.slice(last, index)); + const galleryId = match[1]; + const imageUrl = match[2]; + if (galleryId) html += imgTag(`/media/${galleryId}`); + else if (imageUrl) html += imgTag(imageUrl); + last = index + match[0].length; + } + html += escapeHtml(content.slice(last)); + return html; +} diff --git a/src/admin/views/conversations.ts b/src/admin/views/conversations.ts index ead74d5d..d557f7b7 100644 --- a/src/admin/views/conversations.ts +++ b/src/admin/views/conversations.ts @@ -19,6 +19,7 @@ import { costOfUsage, type ModelId } from "../../pricing"; import { channelLabel } from "../../channels/labels"; import { layout } from "./layout"; import { fmtDateTime } from "../format"; +import { renderMessageHtml } from "../messageHtml"; /** Tiempo relativo corto en español (ej. "hace 5 min", "hace 2 h", "hace 3 d"). */ function ago(ms: number | null | undefined): string { @@ -327,7 +328,7 @@ export async function renderThreadLive(env: Env, convId: string): Promise { const time = fmtDateTime(m.created_at, { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit", - }); + }, env); // Tool chips (what the bot DID this turn) render above the bubble; in a // reversed column that means AFTER it in the DOM. @@ -347,7 +348,7 @@ export async function renderThreadLive(env: Env, convId: string): Promise -
${escapeHtml(m.content)}
+
${renderMessageHtml(m.content)}
${time} `; } @@ -363,7 +364,7 @@ export async function renderThreadLive(env: Env, convId: string): Promise ${chips} -
${escapeHtml(m.content)}
+
${renderMessageHtml(m.content)}
${meta} `; }) diff --git a/src/admin/views/leads.ts b/src/admin/views/leads.ts index 42be2cbf..8b94170b 100644 --- a/src/admin/views/leads.ts +++ b/src/admin/views/leads.ts @@ -27,7 +27,7 @@ export async function renderLeads(env: Env): Promise { // Columnas: núcleo (fecha, nombre, contacto) + o bien las columnas del nicho // (leídas de metadata) o bien el "Resumen" genérico + estado (re-etiquetado). const cols: Col[] = [ - { h: "Fecha", w: "94px", cell: (l) => `${fmtDate(l.created_at)}` }, + { h: "Fecha", w: "94px", cell: (l) => `${fmtDate(l.created_at, {}, env)}` }, { h: "Nombre", w: "minmax(120px,1.1fr)", cell: (l) => `${esc(l.name) || "(sin nombre)"}` }, { h: "Contacto", w: "minmax(110px,1fr)", cell: (l) => `${esc(l.contact) || "—"}` }, ]; @@ -57,7 +57,7 @@ export async function renderLeads(env: Env): Promise { const rows = list .map((l) => { const meta = leadMetadata(l); - const fullDate = fmtDateTime(l.created_at); + const fullDate = fmtDateTime(l.created_at, {}, env); const convLink = l.conversation_id ? ` Ver conversación completa diff --git a/src/admin/views/tickets.ts b/src/admin/views/tickets.ts index 5ac538bf..7b85a623 100644 --- a/src/admin/views/tickets.ts +++ b/src/admin/views/tickets.ts @@ -15,7 +15,7 @@ export async function renderTickets(env: Env): Promise { const list = open .map((t) => { - const date = fmtDateTime(t.created_at); + const date = fmtDateTime(t.created_at, {}, env); const pillColor = STATUS_PILL[t.status] ?? "var(--muted)"; return `
diff --git a/test/admin/format.test.ts b/test/admin/format.test.ts new file mode 100644 index 00000000..b173c136 --- /dev/null +++ b/test/admin/format.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { fmtDateTime, panelTimeZone } from "../../src/admin/format"; + +// 2026-10-08 18:00 UTC. México (sin horario de verano) es UTC−6 → 12:00. +const UTC_18 = Date.UTC(2026, 9, 8, 18, 0, 0); +const HOUR: Intl.DateTimeFormatOptions = { + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", +}; + +describe("fechas del panel", () => { + it("usa la zona del negocio cuando no hay BOT_TIMEZONE", () => { + expect(panelTimeZone(null)).toBe("America/Mexico_City"); + expect(fmtDateTime(UTC_18, HOUR)).toContain("12:00"); + expect(fmtDateTime(UTC_18, HOUR)).not.toContain("18:00"); + }); + + it("BOT_TIMEZONE gana sobre la zona compilada del member", () => { + expect(panelTimeZone({ BOT_TIMEZONE: "UTC" })).toBe("UTC"); + expect(fmtDateTime(UTC_18, HOUR, { BOT_TIMEZONE: "UTC" })).toContain("18:00"); + expect(fmtDateTime(UTC_18, HOUR, { BOT_TIMEZONE: "America/Mexico_City" })).toContain("12:00"); + }); + + it("una zona inválida no tira el panel: cae a la del negocio", () => { + expect(panelTimeZone({ BOT_TIMEZONE: "No/Existe" })).toBe("America/Mexico_City"); + expect(fmtDateTime(UTC_18, HOUR, { BOT_TIMEZONE: "No/Existe" })).toContain("12:00"); + }); +}); diff --git a/test/admin/inbox.test.ts b/test/admin/inbox.test.ts index a4313325..8cdbd967 100644 --- a/test/admin/inbox.test.ts +++ b/test/admin/inbox.test.ts @@ -100,6 +100,22 @@ describe("inbox — page and fragments", () => { expect(html).toContain("$0.00"); // turn cost, 4-decimal format expect(html).toContain("🟢 bot activo"); }); + + it("un mensaje con marcador de galería se ve como imagen, no como código", async () => { + const conv = await convs.getOrCreate("whatsapp", "u-media", "Ana"); + await msgs.append(conv.id, "user", "Te mando la foto\n[[media: img_porta]]"); + + const res = await adminApp.request( + `/conversations/thread/${encodeURIComponent(conv.id)}`, + { headers: AUTH }, + env, + ); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain(' { diff --git a/test/admin/messageHtml.test.ts b/test/admin/messageHtml.test.ts new file mode 100644 index 00000000..ea9fa612 --- /dev/null +++ b/test/admin/messageHtml.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { renderMessageHtml } from "../../src/admin/messageHtml"; + +describe("hilo del panel — marcadores de media", () => { + it("pinta [[media: id]] como imagen y no como texto", () => { + const html = renderMessageHtml("Mira\n[[media: img_abc]]"); + expect(html).toContain(' { + const html = renderMessageHtml("foto\n[IMAGE_URL: https://cdn.example/a.png]"); + expect(html).toContain(' { + const html = renderMessageHtml(' [[media: img_"onclick]] [IMAGE_URL: javascript:alert(1)]'); + expect(html).toContain("<script>"); + expect(html).not.toContain("