diff --git a/src/admin/views/agente.ts b/src/admin/views/agente.ts index 659a8042..9ac44eba 100644 --- a/src/admin/views/agente.ts +++ b/src/admin/views/agente.ts @@ -69,6 +69,11 @@ const TOOL_META: Record = { desc: "Guardrail de abuso: manda a cooldown (default 1h) a quien insulta, spamea, es otro bot o usa al bot como ChatGPT gratis — el bot ignora sus mensajes ese rato.", icon: "shield", }, + pauseSuspectedBot: { + label: "Frenar bots", + desc: "Pausa 24 h una conversación cuando del otro lado hay una cuenta automatizada. No se ofrece en el chat de demo.", + icon: "shield-alert", + }, captureLead: { label: "Capturar lead", desc: "Guarda los datos del cliente interesado (nombre, contacto, intención) en la tabla de leads.", diff --git a/src/demoMode.ts b/src/demoMode.ts new file mode 100644 index 00000000..995872cb --- /dev/null +++ b/src/demoMode.ts @@ -0,0 +1,10 @@ +import type { Env } from "./env"; + +/** + * DEMO_MODE = "on" marca la instancia desechable donde un prospecto prueba el + * bot. Apagado por defecto. "on" sin importar mayúsculas: la misma regla con + * la que el motor enciende el chat público /demo. + */ +export function isDemoMode(env: Pick): boolean { + return (env.DEMO_MODE ?? "").toLowerCase() === "on"; +} diff --git a/src/env.ts b/src/env.ts index f67c4678..1421da96 100644 --- a/src/env.ts +++ b/src/env.ts @@ -21,6 +21,8 @@ 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" = instancia de demo para un prospecto. Apagado por defecto. */ + 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/tools/index.ts b/src/tools/index.ts index d71558dc..0e77c344 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,9 +1,11 @@ import type { Env } from "../env"; import { isPro } from "../config"; +import { isDemoMode } from "../demoMode"; import { searchKbTool } from "./searchKb"; import { handoffHumanTool } from "./handoffHuman"; import { pauseBotTool } from "./pauseBot"; import { snoozeUserTool } from "./snoozeUser"; +import { pauseSuspectedBotTool } from "./pauseSuspectedBot"; import { captureLeadTool } from "./captureLead"; import { scheduleAppointmentTool } from "./scheduleAppointment"; import { catalogQueryTool } from "./catalogQuery"; @@ -27,6 +29,13 @@ export function buildTools(ctx: ToolContext) { scheduleAppointment: scheduleAppointmentTool(ctx.env, ctx.getConversationId), }; + // Anti loop bot-a-bot. En DEMO_MODE un prospecto manda justo lo que esta tool + // confunde con un bot (3–4 preguntas cortas: precio, horario, ubicación) y + // el chat se queda mudo 24 h. En producción la protección se queda. + if (!isDemoMode(ctx.env)) { + tools.pauseSuspectedBot = pauseSuspectedBotTool(ctx.env, ctx.getConversationId); + } + // Pro tier additions if (isPro(ctx.env)) { tools.catalogQuery = catalogQueryTool(ctx.env); diff --git a/src/tools/pauseSuspectedBot.ts b/src/tools/pauseSuspectedBot.ts new file mode 100644 index 00000000..ad27a43b --- /dev/null +++ b/src/tools/pauseSuspectedBot.ts @@ -0,0 +1,41 @@ +import { tool } from "ai"; +import { z } from "zod"; +import type { Env } from "../env"; +import { Db } from "../db/client"; +import { ConversationsRepo } from "../db/conversations"; +import { isDemoMode } from "../demoMode"; + +// Corta un loop bot-a-bot: el LLM concluye que del otro lado hay una cuenta +// AUTOMATIZADA (no una persona) y pausa la conversación para no quemar tokens +// respondiéndole a otro bot. Complementa snoozeUser (abuso/off-topic) y el +// filtro determinístico de src/spam.ts (texto repetido). La decisión es del +// LLM a propósito: un umbral de velocidad no distingue un bot de un lead +// entusiasmado; el criterio sí. La guía dura vive en la description de abajo. +export const SUSPECTED_BOT_PAUSE_MS = 24 * 60 * 60_000; // 24 h + +export function pauseSuspectedBotTool(env: Env, getConversationId: () => string | null) { + return tool({ + description: + "Pausa ESTA conversación 24h cuando concluyas que del otro lado hay un BOT o cuenta AUTOMATIZADA, no una persona real. Es para cortar un loop bot-a-bot que solo quema dinero. Úsala SOLO si ves VARIAS de estas señales JUNTAS: (1) responde al instante, siempre, sin las pausas naturales de un humano que lee y escribe; (2) sus mensajes son genéricos o hacen eco / repiten / reformulan lo que TÚ acabas de decir; (3) la conversación gira en círculos y no avanza a NADA concreto (no pregunta precios reales, no da datos suyos, no agenda, no dice qué necesita); (4) contesta a todo de forma afirmativa/robótica o con preguntas vacías que solo mantienen el ping-pong; (5) llevan muchos turnos sin la menor intención de compra ni información real. Después de llamarla NO mandes ningún mensaje (es un bot, no lo leería y solo alimenta el loop). " + + "NO la uses con una persona real aunque: escriba mucho o muy seguido, esté emocionada, haga muchas preguntas, escriba con errores o raro, o sea insistente/necia. Los humanos reales preguntan cosas ESPECÍFICAS, dan datos propios (su nombre, qué venden, su caso, su ciudad), reaccionan con emoción y TARDAN en responder. Ante la MÍNIMA duda, NO la uses y sigue atendiendo normal: es mucho peor perder un cliente real que gastar unos mensajes de más. Para insultos/abuso o 'ChatGPT gratis' usa snoozeUser; para pasar a un humano usa handoffHuman.", + inputSchema: z.object({ + reason: z + .string() + .describe( + "Las señales de bot que observaste, en 1 frase (ej. 'respuestas instantáneas + hace eco de mis mensajes + 15 turnos sin dar ningún dato real')", + ), + }), + execute: async ({ reason }) => { + // Defensa: en demo la tool ni se registra. Si alguien la invoca igual, + // no pausa — un prospecto que prueba con "¿precio?" no es un bot. + if (isDemoMode(env)) return { skipped: "demo_mode" as const }; + const convId = getConversationId(); + if (!convId) return { error: "no_conversation" as const }; + const convs = new ConversationsRepo(new Db(env.DB)); + const until = Date.now() + SUSPECTED_BOT_PAUSE_MS; + await convs.setPausedUntil(convId, until); + console.warn(`[pauseSuspectedBot] conv ${convId} pausada 24h — bot detectado: ${reason}`); + return { pausedUntil: until, reason }; + }, + }); +} diff --git a/test/admin/agente.test.ts b/test/admin/agente.test.ts index ec8d0fca..28ce1659 100644 --- a/test/admin/agente.test.ts +++ b/test/admin/agente.test.ts @@ -53,14 +53,27 @@ describe("Mi Agente — page and canvas", () => { expect(html).toContain("Respuesta"); expect(html).toContain("Modelo"); expect(html).toContain("Memoria"); - // Pro tier: all six tools appear as nodes - for (const tool of ["searchKb", "handoffHuman", "pauseBot", "captureLead", "scheduleAppointment", "catalogQuery"]) { + // Pro tier: tools de producción, incluida la pausa anti-bot. + for (const tool of ["searchKb", "handoffHuman", "pauseBot", "pauseSuspectedBot", "captureLead", "scheduleAppointment", "catalogQuery"]) { expect(html).toContain(tool); } // Buffer shows the effective seconds from env expect(html).toContain("8 s"); }); + it("oculta pauseSuspectedBot en el canvas cuando DEMO_MODE=on", async () => { + const res = await adminApp.request( + "/agente", + { headers: AUTH }, + { ...env, DEMO_MODE: "on" } as Env, + ); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).not.toContain("pauseSuspectedBot"); + expect(html).toContain("pauseBot"); + expect(html).toContain("searchKb"); + }); + it("shows real channels from the conversations table", async () => { const convs = new ConversationsRepo(new Db(env.DB)); await convs.getOrCreate("telegram", "u1"); diff --git a/test/demoMode.test.ts b/test/demoMode.test.ts new file mode 100644 index 00000000..bf2ebbfb --- /dev/null +++ b/test/demoMode.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from "vitest"; +import { isDemoMode } from "../src/demoMode"; + +describe("isDemoMode", () => { + it("está apagado si la var falta, está vacía o es off", () => { + expect(isDemoMode({})).toBe(false); + expect(isDemoMode({ DEMO_MODE: "" })).toBe(false); + expect(isDemoMode({ DEMO_MODE: "off" })).toBe(false); + }); + + it("se enciende con on, sin importar mayúsculas", () => { + expect(isDemoMode({ DEMO_MODE: "on" })).toBe(true); + expect(isDemoMode({ DEMO_MODE: "ON" })).toBe(true); + }); +}); diff --git a/test/tools/index.test.ts b/test/tools/index.test.ts index 530953a2..7111e67f 100644 --- a/test/tools/index.test.ts +++ b/test/tools/index.test.ts @@ -1,10 +1,11 @@ import { describe, it, expect } from "vitest"; import { buildTools, type ToolContext } from "../../src/tools/index"; -function makeCtx(tier: "free" | "pro", niche?: string): ToolContext { +function makeCtx(tier: "free" | "pro", niche?: string, demoMode?: string): ToolContext { const env = { BOT_TIER: tier, BOT_NICHE: niche, + DEMO_MODE: demoMode, DB: {} as any, AI: {} as any, BUSINESS_NAME: "Test", @@ -15,12 +16,13 @@ function makeCtx(tier: "free" | "pro", niche?: string): ToolContext { } describe("buildTools", () => { - it("registers the 6 free-tier tools (incluye captureLead y scheduleAppointment)", () => { + it("registers the free-tier tools (incluye captureLead, scheduleAppointment y pauseSuspectedBot)", () => { const tools = buildTools(makeCtx("free")); expect(Object.keys(tools).sort()).toEqual([ "captureLead", "handoffHuman", "pauseBot", + "pauseSuspectedBot", "scheduleAppointment", "searchKb", "snoozeUser", @@ -34,13 +36,14 @@ describe("buildTools", () => { expect(tools.catalogQuery).toBeUndefined(); }); - it("pro tier has the 6 base tools plus catalogQuery (Pro)", () => { + it("pro tier has the base tools plus catalogQuery (Pro)", () => { const tools = buildTools(makeCtx("pro")); expect(Object.keys(tools).sort()).toEqual([ "captureLead", "catalogQuery", "handoffHuman", "pauseBot", + "pauseSuspectedBot", "scheduleAppointment", "searchKb", "snoozeUser", @@ -49,6 +52,22 @@ describe("buildTools", () => { expect(tools.catalogQuery).toBeDefined(); }); + it("no registra pauseSuspectedBot cuando DEMO_MODE está on (el prospecto no es un bot)", () => { + for (const mode of ["on", "ON", "On"]) { + const tools = buildTools(makeCtx("pro", undefined, mode)); + expect(tools.pauseSuspectedBot, `DEMO_MODE=${mode}`).toBeUndefined(); + expect(tools.searchKb).toBeDefined(); + expect(tools.pauseBot).toBeDefined(); + expect(tools.snoozeUser).toBeDefined(); + } + }); + + it("registra pauseSuspectedBot en producción (DEMO_MODE apagado o ausente)", () => { + expect(buildTools(makeCtx("free")).pauseSuspectedBot).toBeDefined(); + expect(buildTools(makeCtx("pro", undefined, "off")).pauseSuspectedBot).toBeDefined(); + expect(buildTools(makeCtx("pro", undefined, "")).pauseSuspectedBot).toBeDefined(); + }); + it("el Starter genérico no agrega tools de nicho (aunque BOT_NICHE traiga un giro)", () => { for (const niche of [undefined, "restaurante", "inmobiliaria", "hoteleria"]) { const tools = buildTools(makeCtx("pro", niche)); diff --git a/test/tools/pauseSuspectedBot.test.ts b/test/tools/pauseSuspectedBot.test.ts new file mode 100644 index 00000000..7ae4a047 --- /dev/null +++ b/test/tools/pauseSuspectedBot.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { createTestMiniflare } from "../helpers/miniflareSetup"; +import { Db } from "../../src/db/client"; +import { ConversationsRepo } from "../../src/db/conversations"; +import { + pauseSuspectedBotTool, + SUSPECTED_BOT_PAUSE_MS, +} from "../../src/tools/pauseSuspectedBot"; + +let env: any; +let convs: ConversationsRepo; +let convId: string; + +beforeEach(async () => { + const mf = await createTestMiniflare(); + const d1 = await mf.getD1Database("DB"); + convs = new ConversationsRepo(new Db(d1 as any)); + const conv = await convs.getOrCreate("web", "demo-session"); + convId = conv.id; + env = { DB: d1 }; +}); + +describe("pauseSuspectedBotTool", () => { + it("pausa la conversación 24 h en producción", async () => { + const tool = pauseSuspectedBotTool(env, () => convId); + const before = Date.now(); + await tool.execute!({ reason: "eco + sin datos reales" }, {} as any); + const conv = await convs.getById(convId); + expect(conv?.paused_until).toBeGreaterThanOrEqual(before + SUSPECTED_BOT_PAUSE_MS - 50); + expect(conv!.paused_until!).toBeLessThanOrEqual(Date.now() + SUSPECTED_BOT_PAUSE_MS + 50); + expect(await convs.isPaused(convId)).toBe(true); + }); + + it("no pausa si no hay conversación", async () => { + const tool = pauseSuspectedBotTool(env, () => null); + const result = await tool.execute!({ reason: "bot" }, {} as any); + expect(result).toEqual({ error: "no_conversation" }); + }); + + it("queda inerte con DEMO_MODE=on aunque alguien la invoque", async () => { + const tool = pauseSuspectedBotTool({ ...env, DEMO_MODE: "on" }, () => convId); + const result = await tool.execute!({ reason: "preguntas cortas de demo" }, {} as any); + expect(result).toEqual({ skipped: "demo_mode" }); + expect(await convs.isPaused(convId)).toBe(false); + }); +}); diff --git a/wrangler.toml b/wrangler.toml index c820f9f2..5ae9c1d4 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -35,6 +35,7 @@ BOT_NAME = "{{BOT_NAME}}" BUSINESS_NAME = "{{BUSINESS_NAME}}" BOT_LANGUAGE = "{{BOT_LANGUAGE}}" BOT_TIER = "{{BOT_TIER}}" # 'free' | 'pro' +# DEMO_MODE = "on" # instancia de demo. Con "on" no se registra pauseSuspectedBot. BUFFER_SECONDS = "15" DASHBOARD_BASE_URL = "https://horizontes-bot-{{BOT_SLUG}}.workers.dev"