Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/admin/views/agente.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ const TOOL_META: Record<string, ToolMeta> = {
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.",
Expand Down
10 changes: 10 additions & 0 deletions src/demoMode.ts
Original file line number Diff line number Diff line change
@@ -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<Env, "DEMO_MODE">): boolean {
return (env.DEMO_MODE ?? "").toLowerCase() === "on";
}
2 changes: 2 additions & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down
9 changes: 9 additions & 0 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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);
Expand Down
41 changes: 41 additions & 0 deletions src/tools/pauseSuspectedBot.ts
Original file line number Diff line number Diff line change
@@ -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 };
},
});
}
17 changes: 15 additions & 2 deletions test/admin/agente.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
15 changes: 15 additions & 0 deletions test/demoMode.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
25 changes: 22 additions & 3 deletions test/tools/index.test.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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));
Expand Down
46 changes: 46 additions & 0 deletions test/tools/pauseSuspectedBot.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
1 change: 1 addition & 0 deletions wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Loading