diff --git a/src/channels/labels.ts b/src/channels/labels.ts index 7f2a649d..1d22f100 100644 --- a/src/channels/labels.ts +++ b/src/channels/labels.ts @@ -13,6 +13,7 @@ export const CHANNEL_LABELS: Record = { instagram: "Instagram", messenger: "Messenger", manychat: "ManyChat", + web: "Web", }; export function channelLabel(channel: string | null | undefined): string { diff --git a/src/channels/shared.ts b/src/channels/shared.ts index 08665db1..0bed2b8d 100644 --- a/src/channels/shared.ts +++ b/src/channels/shared.ts @@ -1,4 +1,4 @@ -export type ChannelId = "manychat" | "telegram" | "twilio" | "messenger" | "instagram" | "whatsapp"; +export type ChannelId = "manychat" | "telegram" | "twilio" | "messenger" | "instagram" | "whatsapp" | "web"; export interface IncomingMessage { channel: ChannelId; diff --git a/src/channels/web.ts b/src/channels/web.ts new file mode 100644 index 00000000..ea26e94d --- /dev/null +++ b/src/channels/web.ts @@ -0,0 +1,39 @@ +import type { ChannelAdapter, IncomingMessage, OutgoingReply } from "./shared"; +import type { Env } from "../env"; + +/** + * Canal WEB — el chat de /demo. + * + * No hay proveedor al que empujar la respuesta: el agente la deja en D1 y el + * navegador la recoge con GET /demo/poll. sendReply es un no-op a propósito, + * para que este canal reutilice el mismo pipeline (buffer, tools, KB) sin + * ramas especiales. + */ +export const webAdapter: ChannelAdapter = { + async parseIncoming(request: Request, _env: Env): Promise { + const body = (await request.json().catch(() => ({}))) as { + sessionId?: string; + text?: string; + name?: string; + }; + + const sessionId = (body.sessionId ?? "").trim(); + const text = (body.text ?? "").trim(); + if (!sessionId || !text) { + throw new Error("web: falta sessionId o text"); + } + + return { + channel: "web", + // El sessionId lo genera el navegador. Acotado para que no ensucie el + // nombre del Durable Object. + channelUserId: sessionId.slice(0, 64), + displayName: (body.name ?? "").trim().slice(0, 60) || undefined, + text: text.slice(0, 2000), + receivedAt: Date.now(), + rawPayload: body, + }; + }, + + async sendReply(_reply: OutgoingReply, _env: Env): Promise {}, +}; diff --git a/src/demo.ts b/src/demo.ts new file mode 100644 index 00000000..a3d41ab2 --- /dev/null +++ b/src/demo.ts @@ -0,0 +1,277 @@ +import type { Context } from "hono"; +import type { Env } from "./env"; +import { Db } from "./db/client"; + +/** + * MODO DEMO — chat web público para enseñarle el bot a un prospecto. + * + * Se enciende con `DEMO_MODE = "on"`. Apagado por defecto: un bot de producción + * no debe exponer un chat sin autenticar. El visitante manda POST /demo/send + * (canal `web`, el mismo pipeline del agente) y la página recoge la respuesta + * con GET /demo/poll. + * + * El cursor de ese poll es `created_at` del SERVIDOR. Si el cliente lo arma con + * el reloj del navegador (`Date.now()`), un reloj adelantado pide mensajes + * "del futuro" y el poll vuelve vacío aunque la respuesta ya esté en el panel. + * Por eso la página, al cargar, pide `?after=0`, se queda con el `at` más alto + * que ya existe y no pinta ese historial. Nunca usa el reloj del visitante. + * + * Tampoco corta el sondeo en la primera burbuja: con `max_chunks` las siguientes + * llegan unos segundos después. Sigue pidiendo ~6 s desde la última burbuja. + */ + +/** Tope de turnos por sesión: un demo real no pasa de unas pocas preguntas. */ +const MAX_TURNS_PER_SESSION = 40; + +/** Tras la última burbuja, el sondeo rápido sigue este rato por si hay más. */ +export const DEMO_FOLLOW_MS = 6000; + +/** Si para entonces no llegó nada, se deja de esperar y se suelta el input. */ +export const DEMO_GIVE_UP_MS = 90_000; + +export function demoEnabled(env: Env): boolean { + return (env.DEMO_MODE ?? "").toLowerCase() === "on"; +} + +/** Cuántos mensajes del visitante lleva esta sesión (anti-abuso de costo). */ +export async function demoTurnsUsed(env: Env, sessionId: string): Promise { + const db = new Db(env.DB); + const row = await db.first<{ n: number }>( + `SELECT COUNT(*) AS n FROM messages m + JOIN conversations c ON c.id = m.conversation_id + WHERE c.channel = 'web' AND c.channel_user_id = ? AND m.role = 'user'`, + [sessionId], + ); + return row?.n ?? 0; +} + +export function demoOverLimit(used: number): boolean { + return used >= MAX_TURNS_PER_SESSION; +} + +export interface DemoPollMessage { + id: string; + text: string; + at: number; +} + +/** GET /demo/poll?session=…&after=… → mensajes del bot posteriores a `after`. */ +export async function demoPoll(c: Context<{ Bindings: Env }>) { + if (!demoEnabled(c.env)) return c.json({ ok: false, error: "demo_off" }, 404); + + const sessionId = (c.req.query("session") ?? "").slice(0, 64); + const after = Number(c.req.query("after") ?? 0) || 0; + if (!sessionId) return c.json({ ok: false, error: "missing_session" }, 400); + + const db = new Db(c.env.DB); + const conv = await db.first<{ id: string }>( + "SELECT id FROM conversations WHERE channel = 'web' AND channel_user_id = ? LIMIT 1", + [sessionId], + ); + if (!conv) return c.json({ ok: true, messages: [] }); + + const rows = await db.all<{ id: string; content: string; created_at: number }>( + `SELECT id, content, created_at FROM messages + WHERE conversation_id = ? AND created_at > ? AND role = 'assistant' + ORDER BY created_at ASC LIMIT 20`, + [conv.id, after], + ); + + const messages: DemoPollMessage[] = rows.map((r) => ({ + id: r.id, + text: r.content, + at: r.created_at, + })); + return c.json({ ok: true, messages }); +} + +const esc = (s: string) => + s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + +/** + * JS del chat. El cursor (`since`) solo avanza con `at` que devolvió el + * servidor. `seen` evita pintar dos veces la misma fila si dos sondeos se + * cruzan. La clave es el id; si no viniera, at+texto, para no tirar una + * segunda burbuja que compartiera milisegundo con la primera. + */ +export function demoClientScript(): string { + return `(function(){ + var KEY='forja_demo_session'; + var sid=localStorage.getItem(KEY); + if(!sid){ sid=(crypto.randomUUID?crypto.randomUUID():String(Date.now())+Math.random().toString(16).slice(2)); + localStorage.setItem(KEY,sid); } + + var chat=document.getElementById('chat'), ta=document.getElementById('t'), btn=document.getElementById('s'); + var since=0, polling=null, followTimer=null, giveUpTimer=null, waiting=false, seen={}; + + function scroll(){ chat.scrollTop=chat.scrollHeight; } + function add(text,who,cls){ + var r=document.createElement('div'); r.className='row '+who; + var b=document.createElement('div'); b.className='bub'+(cls?' '+cls:''); b.textContent=text; + r.appendChild(b); chat.appendChild(r); scroll(); return r; + } + function typing(){ + var r=document.createElement('div'); r.className='row bot'; r.id='typing'; + r.innerHTML='
'; + chat.appendChild(r); scroll(); return r; + } + function stopTyping(){ var e=document.getElementById('typing'); if(e) e.remove(); } + + ta.addEventListener('input',function(){ ta.style.height='auto'; ta.style.height=Math.min(ta.scrollHeight,120)+'px'; }); + ta.addEventListener('keydown',function(e){ if(e.key==='Enter'&&!e.shiftKey){ e.preventDefault(); send(); } }); + btn.addEventListener('click',send); + + function clave(m){ return m.id || (String(m.at)+"\\n"+m.text); } + function tomar(m, pintar){ + var k=clave(m); + if(seen[k]) return false; + seen[k]=1; + if(typeof m.at==='number' && m.at>since) since=m.at; + if(pintar) add(m.text,'bot'); + return true; + } + + function pararSondeo(){ + if(polling){ clearInterval(polling); polling=null; } + } + + function poll(){ + fetch('/demo/poll?session='+encodeURIComponent(sid)+'&after='+since) + .then(function(r){ return r.json(); }) + .then(function(d){ + if(!d||!d.ok||!d.messages||!d.messages.length) return; + var nuevos=0; + d.messages.forEach(function(m){ if(tomar(m,true)) nuevos++; }); + if(!nuevos) return; + stopTyping(); + // No cerrar en la primera burbuja: las siguientes (max_chunks) llegan + // un poco después. El reloj de gracia corre desde la ÚLTIMA. + if(followTimer) clearTimeout(followTimer); + followTimer=setTimeout(function(){ + followTimer=null; + waiting=false; btn.disabled=false; + pararSondeo(); + }, ${DEMO_FOLLOW_MS}); + }).catch(function(){}); + } + + // Una sola sincronización, al cargar, y el envío espera a que termine. + // Así el cursor sale del servidor y un poll tardío no se traga la respuesta + // que acaba de generar el turno (la marcaría como "ya vista" sin pintarla). + var listo=new Promise(function(resolve){ + fetch('/demo/poll?session='+encodeURIComponent(sid)+'&after=0') + .then(function(r){ return r.json(); }) + .then(function(d){ + if(d&&d.ok&&d.messages) d.messages.forEach(function(m){ tomar(m,false); }); + }) + .catch(function(){}) + .then(resolve); + }); + + function send(){ + var text=ta.value.trim(); if(!text||waiting) return; + waiting=true; btn.disabled=true; + listo.then(function(){ + add(text,'me'); ta.value=''; ta.style.height='auto'; + typing(); + fetch('/demo/send',{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({sessionId:sid,text:text})}) + .then(function(r){ return r.json().catch(function(){ return {ok:r.ok}; }); }) + .then(function(d){ + if(d&&d.ok===false){ + stopTyping(); waiting=false; btn.disabled=false; + add(d.error==='limit' + ? ${JSON.stringify("Se acabó la demo 🙂 Escríbele al dueño para seguir la conversación.")} + : ${JSON.stringify("Ups, algo falló. Intenta de nuevo.")},'bot','err'); + return; + } + pararSondeo(); + if(followTimer){ clearTimeout(followTimer); followTimer=null; } + polling=setInterval(poll,1200); + if(giveUpTimer) clearTimeout(giveUpTimer); + giveUpTimer=setTimeout(function(){ + giveUpTimer=null; + if(!waiting) return; + stopTyping(); waiting=false; btn.disabled=false; + pararSondeo(); + if(followTimer){ clearTimeout(followTimer); followTimer=null; } + }, ${DEMO_GIVE_UP_MS}); + }) + .catch(function(){ stopTyping(); waiting=false; btn.disabled=false; + add(${JSON.stringify("Sin conexión. Revisa tu internet.")},'bot','err'); }); + }); + } +})();`; +} + +/** GET /demo → la página de chat, con la marca del negocio. */ +export function demoPage(c: Context<{ Bindings: Env }>) { + if (!demoEnabled(c.env)) return c.text("Not found", 404); + + const business = esc(c.env.BUSINESS_NAME || "Tu negocio"); + const bot = esc(c.env.BOT_NAME || business); + const initial = (c.env.BUSINESS_NAME || c.env.BOT_NAME || "•").trim().charAt(0).toUpperCase() || "•"; + + return c.html(` + + + +${bot} + + + +
+
${esc(initial)}
+
${bot}En línea
+
+
+
¡Hola! Soy el asistente de ${business}. ¿En qué te puedo ayudar?
+
+
+ + +
+
Demo de ${business} · atendido por IA
+ +`); +} diff --git a/src/env.ts b/src/env.ts index f67c4678..f13cb93f 100644 --- a/src/env.ts +++ b/src/env.ts @@ -21,6 +21,9 @@ export interface Env { // re-etiqueta el dashboard, aporta el playbook del giro y sus columnas. // Ausente/desconocido → pack genérico (comportamiento actual). Ver src/niches/. BOT_NICHE?: string; + // "on" enciende el chat público de /demo. Apagado por defecto: un bot de + // producción no debe exponer un chat sin autenticar (es acceso a la llave de IA). + DEMO_MODE?: string; // LLM provider for the chat brain: "anthropic" (default) | "openai". // If unset and only OPENAI_API_KEY is present, auto-selects "openai". // (Voice transcription + embeddings always run on Cloudflare Workers AI.) diff --git a/src/index.ts b/src/index.ts index 45c61d68..0e32d687 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,8 @@ import { saveCapture, isLearnMode } from "./learn/mapping"; import { tokensMatch, manychatWebhookAllowed } from "./http-auth"; import { apiApp } from "./api"; import { getAgentStub } from "./agentStub"; +import { webAdapter } from "./channels/web"; +import { demoEnabled, demoOverLimit, demoPage, demoPoll, demoTurnsUsed } from "./demo"; export { SupportAgent } from "./agent"; @@ -55,6 +57,21 @@ async function routeToAgent(c: { req: { raw: Request }; env: Env; text: (t: stri } } +// Chat público de /demo. Apagado salvo DEMO_MODE=on. El cursor del poll lo +// pone el cliente con la hora del servidor (ver src/demo.ts), no con la del +// navegador. +app.get("/demo", (c) => demoPage(c)); +app.get("/demo/poll", (c) => demoPoll(c)); +app.post("/demo/send", async (c) => { + if (!demoEnabled(c.env)) return c.json({ ok: false, error: "demo_off" }, 404); + const body = (await c.req.raw.clone().json().catch(() => ({}))) as { sessionId?: string }; + const sid = String(body.sessionId ?? "").slice(0, 64); + if (sid && demoOverLimit(await demoTurnsUsed(c.env, sid))) { + return c.json({ ok: false, error: "limit" }, 429); + } + return routeToAgent(c, webAdapter); +}); + app.post("/webhooks/telegram", (c) => routeToAgent(c, telegramAdapter)); // ManyChat — guarded by the X-Api-Key header the setup guide already asks for. // No-op until MANYCHAT_WEBHOOK_SECRET is set, so existing bots keep working. diff --git a/src/replies/sender.ts b/src/replies/sender.ts index a11f2421..379dc050 100644 --- a/src/replies/sender.ts +++ b/src/replies/sender.ts @@ -5,6 +5,7 @@ import { manychatAdapter } from "../channels/manychat"; import { twilioAdapter } from "../channels/twilio"; import { metaAdapter } from "../channels/meta"; import { whatsappAdapter } from "../channels/whatsapp"; +import { webAdapter } from "../channels/web"; const MIN_DELAY_MS = 800; const MAX_DELAY_MS = 1500; @@ -41,5 +42,6 @@ export function pickAdapter(channel: ChannelId): ChannelAdapter { if (channel === "twilio") return twilioAdapter; if (channel === "whatsapp") return whatsappAdapter; if (channel === "messenger" || channel === "instagram") return metaAdapter; + if (channel === "web") return webAdapter; throw new Error(`unknown channel: ${channel}`); } diff --git a/test/channels/web.test.ts b/test/channels/web.test.ts new file mode 100644 index 00000000..fecd044b --- /dev/null +++ b/test/channels/web.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { webAdapter } from "../../src/channels/web"; + +function req(body: unknown): Request { + return new Request("https://bot.test/demo/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("webAdapter", () => { + it("normaliza sessionId y texto", async () => { + const msg = await webAdapter.parseIncoming( + req({ sessionId: "abc-123", text: " hola ", name: "Ana" }), + {} as any, + ); + expect(msg.channel).toBe("web"); + expect(msg.channelUserId).toBe("abc-123"); + expect(msg.text).toBe("hola"); + expect(msg.displayName).toBe("Ana"); + }); + + it("rechaza un envío vacío", async () => { + await expect(webAdapter.parseIncoming(req({ sessionId: "", text: "hola" }), {} as any)) + .rejects.toThrow(/sessionId/); + }); + + it("sendReply no llama a nadie", async () => { + await expect(webAdapter.sendReply({ + channel: "web", + channelUserId: "s", + chunks: ["uno", "dos"], + }, {} as any)).resolves.toBeUndefined(); + }); +}); diff --git a/test/demo.test.ts b/test/demo.test.ts new file mode 100644 index 00000000..b876a2d0 --- /dev/null +++ b/test/demo.test.ts @@ -0,0 +1,286 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; + +vi.mock("agents", () => ({ Agent: class {} })); + +import worker from "../src/index"; +import { demoClientScript, demoEnabled, demoOverLimit, DEMO_FOLLOW_MS, DEMO_GIVE_UP_MS } from "../src/demo"; +import { createTestMiniflare } from "./helpers/miniflareSetup"; +import { Db } from "../src/db/client"; +import { ConversationsRepo } from "../src/db/conversations"; +import { MessagesRepo } from "../src/db/messages"; + +const baseEnv = { + BOT_NAME: "Fer", + BUSINESS_NAME: "Barbería Atlas", + BOT_LANGUAGE: "es", + BOT_TIER: "pro", + BUFFER_SECONDS: "15", + DASHBOARD_BASE_URL: "https://test.workers.dev", +} as any; + +const on = { ...baseEnv, DEMO_MODE: "on" }; + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("modo demo — interruptor", () => { + it("apagado por defecto", () => { + expect(demoEnabled(baseEnv)).toBe(false); + expect(demoEnabled({ ...baseEnv, DEMO_MODE: "off" })).toBe(false); + }); + + it("se enciende con DEMO_MODE=on, sin importar mayúsculas", () => { + expect(demoEnabled(on)).toBe(true); + expect(demoEnabled({ ...baseEnv, DEMO_MODE: "ON" })).toBe(true); + }); + + it("corta la sesión al llegar al tope de turnos", () => { + expect(demoOverLimit(39)).toBe(false); + expect(demoOverLimit(40)).toBe(true); + }); +}); + +describe("modo demo — rutas", () => { + it("/demo da 404 cuando está apagado", async () => { + const res = await worker.fetch(new Request("https://test/demo"), baseEnv, {} as any); + expect(res.status).toBe(404); + expect(await res.text()).toBe("Not found"); + }); + + it("/demo/send da 404 cuando está apagado", async () => { + const res = await worker.fetch( + new Request("https://test/demo/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId: "s1", text: "hola" }), + }), + baseEnv, + {} as any, + ); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ ok: false, error: "demo_off" }); + }); + + it("sirve la página con la marca y sin el cursor del reloj del navegador", async () => { + const res = await worker.fetch(new Request("https://test/demo"), on, {} as any); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("Barbería Atlas"); + expect(html).toContain("/demo/send"); + expect(html).toContain("/demo/poll"); + expect(html).toContain("after=0"); + expect(html).not.toMatch(/since\s*=\s*Date\.now\(\)/); + expect(html).toContain(String(DEMO_FOLLOW_MS)); + expect(html).toContain(String(DEMO_GIVE_UP_MS)); + }); + + it("escapa el nombre del negocio", async () => { + const res = await worker.fetch( + new Request("https://test/demo"), + { ...on, BUSINESS_NAME: "" } as any, + {} as any, + ); + const html = await res.text(); + expect(html).not.toContain(""); + expect(html).toContain("<script>"); + }); + + it("/demo/poll exige sesión", async () => { + const res = await worker.fetch(new Request("https://test/demo/poll"), on, {} as any); + expect(res.status).toBe(400); + }); + + it("/demo/poll filtra por created_at del servidor y no devuelve al visitante", async () => { + const mf = await createTestMiniflare(); + const db = new Db((await mf.getD1Database("DB")) as any); + const conv = await new ConversationsRepo(db).getOrCreate("web", "sesion-1"); + const msgs = new MessagesRepo(db); + await msgs.append(conv.id, "user", "hola", { createdAt: 1000 }); + await msgs.append(conv.id, "assistant", "primera", { createdAt: 1500 }); + await msgs.append(conv.id, "assistant", "segunda", { createdAt: 3000 }); + + const env = { ...on, DB: db.d1 }; + const pedir = (after: number) => + worker.fetch(new Request(`https://test/demo/poll?session=sesion-1&after=${after}`), env, {} as any); + + const todo = await (await pedir(0)).json() as { messages: { id: string; text: string; at: number }[] }; + expect(todo.messages.map((m) => m.text)).toEqual(["primera", "segunda"]); + expect(todo.messages[0].at).toBe(1500); + expect(todo.messages[0].id).toBeTruthy(); + + const solo = await (await pedir(1500)).json() as { messages: { text: string }[] }; + expect(solo.messages.map((m) => m.text)).toEqual(["segunda"]); + + const nada = await (await pedir(3000)).json() as { messages: unknown[] }; + expect(nada.messages).toEqual([]); + }); + + it("/demo/poll devuelve lista vacía si la sesión no existe", async () => { + const mf = await createTestMiniflare(); + const env = { ...on, DB: await mf.getD1Database("DB") }; + const res = await worker.fetch( + new Request("https://test/demo/poll?session=nueva&after=0"), + env, + {} as any, + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true, messages: [] }); + }); + + it("rechaza el turno 41 sin llamar al agente", async () => { + const env = { + ...on, + DB: { + prepare: (sql: string) => ({ + bind: () => ({ + first: async () => (sql.includes("COUNT") ? { n: 40 } : null), + all: async () => ({ results: [] }), + }), + }), + }, + }; + const res = await worker.fetch( + new Request("https://test/demo/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId: "s1", text: "otra" }), + }), + env, + {} as any, + ); + expect(res.status).toBe(429); + expect(await res.json()).toEqual({ ok: false, error: "limit" }); + }); +}); + +describe("cliente de /demo", () => { + it("no arma el cursor con el reloj y no corta el sondeo en la primera burbuja", () => { + const script = demoClientScript(); + expect(script).not.toMatch(/since\s*=\s*Date\.now\(\)/); + expect(script).toContain("&after=0"); + expect(script).toContain("seen["); + const pollFn = script.slice(script.indexOf("function poll("), script.indexOf("var listo")); + // pararSondeo solo corre dentro del timeout de gracia, no al pintar la primera burbuja. + const antesDelTimeout = pollFn.slice(0, pollFn.indexOf("setTimeout")); + expect(antesDelTimeout).not.toContain("pararSondeo"); + expect(pollFn).toContain("pararSondeo()"); + expect(pollFn).toContain("followTimer"); + expect(pollFn).toContain(String(DEMO_FOLLOW_MS)); + }); + + it("pinta la respuesta aunque el reloj del visitante vaya adelantado, y la burbuja siguiente", async () => { + vi.useFakeTimers(); + const serverNow = 1_700_000_000_000; + vi.setSystemTime(serverNow + 3 * 60_000); + + const stored: { id: string; text: string; at: number }[] = [ + { id: "old", text: "historial", at: serverNow - 10_000 }, + ]; + const polls: number[] = []; + + const fetchMock = vi.fn(async (url: string) => { + const u = new URL(url, "https://test"); + if (u.pathname === "/demo/poll") { + const after = Number(u.searchParams.get("after") ?? 0); + polls.push(after); + return new Response(JSON.stringify({ + ok: true, + messages: stored.filter((m) => m.at > after), + })); + } + stored.push({ id: "a", text: "primera burbuja", at: serverNow }); + return new Response("ok", { status: 200 }); + }); + + const bubbles: string[] = []; + const nodes = new Map(); + function makeEl() { + const node: any = { + style: {}, + children: [] as any[], + className: "", + textContent: "", + innerHTML: "", + _id: "", + listeners: {} as Record, + appendChild(child: any) { + this.children.push(child); + if (this._id === "chat" && child.textContent) bubbles.push(child.textContent); + // el texto vive en .bub, no en el row + if (child.className?.startsWith?.("bub") && this.className?.includes?.("row")) { + bubbles.push(child.textContent); + } + return child; + }, + addEventListener(ev: string, fn: Function) { this.listeners[ev] = fn; }, + remove() {}, + }; + Object.defineProperty(node, "id", { + get: () => node._id, + set: (v: string) => { node._id = v; nodes.set(v, node); }, + }); + return node; + } + + const chat = makeEl(); + chat.id = "chat"; + const ta = makeEl(); + ta.id = "t"; + ta.value = ""; + const btn = makeEl(); + btn.id = "s"; + btn.disabled = false; + const document = { + getElementById: (id: string) => nodes.get(id) ?? null, + createElement: () => makeEl(), + }; + const localStorage = { + getItem: () => "sesion-prueba", + setItem: () => {}, + }; + + const runner = new Function( + "document", "localStorage", "crypto", "fetch", + "setInterval", "clearInterval", "setTimeout", "clearTimeout", + "encodeURIComponent", + demoClientScript(), + ); + runner( + document, localStorage, + { randomUUID: () => "uuid-fijo" }, + fetchMock, + globalThis.setInterval, + globalThis.clearInterval, + globalThis.setTimeout, + globalThis.clearTimeout, + encodeURIComponent, + ); + + await vi.advanceTimersByTimeAsync(0); + await Promise.resolve(); + await Promise.resolve(); + + expect(bubbles).not.toContain("historial"); + expect(polls[0]).toBe(0); + + ta.value = "hola"; + btn.listeners.click(); + await vi.advanceTimersByTimeAsync(0); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + // El primer sondeo del turno pide desde el cursor del servidor (el historial), + // no desde el reloj adelantado. + await vi.advanceTimersByTimeAsync(1200); + expect(polls.some((n) => n >= serverNow + 60_000)).toBe(false); + expect(bubbles).toContain("primera burbuja"); + + stored.push({ id: "b", text: "segunda burbuja", at: serverNow + 1500 }); + await vi.advanceTimersByTimeAsync(1200); + expect(bubbles).toContain("segunda burbuja"); + }); +}); diff --git a/wrangler.toml b/wrangler.toml index c820f9f2..fd7b3498 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -37,6 +37,7 @@ BOT_LANGUAGE = "{{BOT_LANGUAGE}}" BOT_TIER = "{{BOT_TIER}}" # 'free' | 'pro' BUFFER_SECONDS = "15" DASHBOARD_BASE_URL = "https://horizontes-bot-{{BOT_SLUG}}.workers.dev" +# DEMO_MODE = "on" # chat público en /demo. Déjalo comentado en producción. # Optional egress (ticket 7E5F40): Worker→Anthropic/Zernio 400 vacío. # AGENT_LOCATION_HINT = "wnam" # pin DOs near US (only on FIRST create)