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
14 changes: 14 additions & 0 deletions src/db/tickets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ export class TicketsRepo {
return this.db.first<Ticket>("SELECT * FROM tickets WHERE id = ?", [id]);
}

/**
* Ticket aún abierto (open o in_progress) de esta conversación, el más reciente.
* Sirve para no abrir otro cuando el cliente repite el pedido de humano.
*/
async findOpenByConversation(conversationId: string): Promise<Ticket | null> {
return this.db.first<Ticket>(
`SELECT * FROM tickets
WHERE conversation_id = ? AND status != 'resolved'
ORDER BY created_at DESC
LIMIT 1`,
[conversationId],
);
}

async listOpen(): Promise<Ticket[]> {
return this.db.all<Ticket>(
"SELECT * FROM tickets WHERE status != 'resolved' ORDER BY created_at DESC",
Expand Down
14 changes: 13 additions & 1 deletion src/tools/handoffHuman.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { isPro } from "../config";
export function handoffHumanTool(env: Env, getConversationId: () => string | null) {
return tool({
description:
"Crea un ticket para el dueño + le manda email. Usalo cuando el bot no puede resolver o el cliente pide humano explícitamente.",
"Crea un ticket para el dueño + le manda email. Usalo cuando el bot no puede resolver o el cliente pide humano explícitamente. Si esa conversación ya tiene un ticket abierto, reutiliza ese y no avisa otra vez.",
inputSchema: z.object({
reason: z.string().describe("Categoría corta del problema"),
summary: z.string().max(300).describe("Resumen en 1 frase del contexto"),
Expand All @@ -20,6 +20,18 @@ export function handoffHumanTool(env: Env, getConversationId: () => string | nul
const convId = getConversationId();
const db = new Db(env.DB);
const tickets = new TicketsRepo(db);

// El cliente que insiste ("quiero una persona" varias veces) no abre un
// ticket nuevo ni vuelve a avisar al dueño. open_ticket_id queda en el
// ticket que sigue abierto, no en un duplicado.
if (convId) {
const existing = await tickets.findOpenByConversation(convId);
if (existing) {
await new ConversationsRepo(db).setOpenTicket(convId, existing.id);
return { ticketId: existing.id };
}
}

const ticketId = await tickets.create({
conversationId: convId,
category,
Expand Down
95 changes: 93 additions & 2 deletions test/tools/handoffHuman.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createTestMiniflare } from "../helpers/miniflareSetup";
import { Db } from "../../src/db/client";
import { TicketsRepo } from "../../src/db/tickets";
Expand All @@ -7,16 +7,22 @@ import { handoffHumanTool } from "../../src/tools/handoffHuman";

let env: any;
let tickets: TicketsRepo;
let convs: ConversationsRepo;
let convId: string;

afterEach(() => {
vi.restoreAllMocks();
});

beforeEach(async () => {
const mf = await createTestMiniflare();
const d1 = await mf.getD1Database("DB");
const db = new Db(d1 as any);
tickets = new TicketsRepo(db);
convs = new ConversationsRepo(db);
// The tickets table FKs conversation_id -> conversations(id), so we need a
// real conversation row before the tool can attach a ticket to it.
const conv = await new ConversationsRepo(db).getOrCreate("telegram", "u1");
const conv = await convs.getOrCreate("telegram", "u1");
convId = conv.id;
env = {
DB: d1,
Expand Down Expand Up @@ -45,4 +51,89 @@ describe("handoffHumanTool", () => {
expect(list).toHaveLength(1);
expect(list[0].summary).toContain("María");
});

it("reuses the open ticket and notifies the owner only once when the customer repeats the request", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("{}", { status: 200 }));
const envTelegram = {
...env,
RESEND_API_KEY: undefined,
TELEGRAM_BOT_TOKEN: "123:abc",
OWNER_TELEGRAM_CHAT_ID: "99",
};
const tool = handoffHumanTool(envTelegram, () => convId);
const args = {
reason: "humano",
summary: "quiere hablar con una persona",
category: "product" as const,
};

const first = await tool.execute!(args, {} as any);
const second = await tool.execute!(args, {} as any);
const third = await tool.execute!(
{ ...args, summary: "insiste, que lo comuniquen ya" },
{} as any,
);

const ticketId = (first as { ticketId: string }).ticketId;
expect((second as { ticketId: string }).ticketId).toBe(ticketId);
expect((third as { ticketId: string }).ticketId).toBe(ticketId);

const list = await tickets.listOpen();
expect(list).toHaveLength(1);
expect(list[0].id).toBe(ticketId);

const conv = await convs.getById(convId);
expect(conv?.open_ticket_id).toBe(ticketId);

const telegramCalls = fetchSpy.mock.calls.filter((c) =>
String(c[0]).includes("api.telegram.org"),
);
expect(telegramCalls).toHaveLength(1);
});

it("reuses a ticket that is already in progress", async () => {
const envNoResend = { ...env, RESEND_API_KEY: undefined };
const tool = handoffHumanTool(envNoResend, () => convId);
const first = await tool.execute!(
{ reason: "humano", summary: "primera", category: "other" },
{} as any,
);
const ticketId = (first as { ticketId: string }).ticketId;
await env.DB.prepare("UPDATE tickets SET status = 'in_progress' WHERE id = ?").bind(ticketId).run();

const second = await tool.execute!(
{ reason: "humano", summary: "sigue pidiendo una persona", category: "other" },
{} as any,
);
expect((second as { ticketId: string }).ticketId).toBe(ticketId);
const open = await tickets.listOpen();
expect(open).toHaveLength(1);
expect(open[0].id).toBe(ticketId);
expect((await convs.getById(convId))?.open_ticket_id).toBe(ticketId);
});

it("opens a new ticket after the previous one was resolved", async () => {
const envNoResend = { ...env, RESEND_API_KEY: undefined };
const tool = handoffHumanTool(envNoResend, () => convId);
const args = {
reason: "humano",
summary: "primera vez",
category: "product" as const,
};
const first = await tool.execute!(args, {} as any);
const firstId = (first as { ticketId: string }).ticketId;
await tickets.resolve(firstId, "owner@test.com");

const second = await tool.execute!(
{ ...args, summary: "volvió a pedir una persona" },
{} as any,
);
const secondId = (second as { ticketId: string }).ticketId;
expect(secondId).not.toBe(firstId);

const open = await tickets.listOpen();
expect(open).toHaveLength(1);
expect(open[0].id).toBe(secondId);
expect((await convs.getById(convId))?.open_ticket_id).toBe(secondId);
});
});
Loading