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
9 changes: 9 additions & 0 deletions src/db/tickets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@ export class TicketsRepo {
);
}

/** Añade contexto a un ticket que sigue abierto (mismo hilo, sin crear otro). */
async appendSummary(id: string, extra: string): Promise<void> {
const ticket = await this.getById(id);
if (!ticket) return;
// Conserva el contexto más reciente si el hilo se alarga.
const summary = `${ticket.summary}\n${extra}`.slice(-4000);
await this.db.run("UPDATE tickets SET summary = ? WHERE id = ?", [summary, id]);
}

async resolve(id: string, resolvedBy: string): Promise<void> {
await this.db.run(
"UPDATE tickets SET status = 'resolved', resolved_at = ?, resolved_by = ? WHERE id = ?",
Expand Down
23 changes: 20 additions & 3 deletions src/tools/handoffHuman.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,31 @@ export function handoffHumanTool(env: Env, getConversationId: () => string | nul
const convId = getConversationId();
const db = new Db(env.DB);
const tickets = new TicketsRepo(db);
const convs = convId ? new ConversationsRepo(db) : null;
const note = `[${reason}] ${summary}`;

// Un cliente que insiste sobre el mismo traspaso no abre otro ticket:
// el anterior quedaría abierto, huérfano de la conversación, y el dueño
// recibiría un aviso por cada mensaje.
if (convId && convs) {
const conv = await convs.getById(convId);
const existingId = conv?.open_ticket_id;
if (existingId) {
const existing = await tickets.getById(existingId);
if (existing && existing.status !== "resolved") {
await tickets.appendSummary(existing.id, note);
return { ticketId: existing.id };
}
}
}

const ticketId = await tickets.create({
conversationId: convId,
category,
summary: `[${reason}] ${summary}`,
summary: note,
transcript: "", // populated by agent if it has access; left blank otherwise
});
if (convId) {
const convs = new ConversationsRepo(db);
if (convId && convs) {
await convs.setOpenTicket(convId, ticketId);
}

Expand Down
85 changes: 84 additions & 1 deletion 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 Down Expand Up @@ -28,6 +28,10 @@ beforeEach(async () => {
};
});

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

describe("handoffHumanTool", () => {
it("creates a ticket row in D1 even without Resend key", async () => {
const envNoResend = { ...env, RESEND_API_KEY: undefined };
Expand All @@ -45,4 +49,83 @@ describe("handoffHumanTool", () => {
expect(list).toHaveLength(1);
expect(list[0].summary).toContain("María");
});

it("reuses the open ticket on later handoffs and notifies the owner only once", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("ok"));
const envNotify = {
...env,
RESEND_API_KEY: undefined,
TELEGRAM_BOT_TOKEN: "123:abc",
OWNER_TELEGRAM_CHAT_ID: "999",
};
const tool = handoffHumanTool(envNotify, () => convId);
const first = await tool.execute!(
{ reason: "humano", summary: "quiere que lo llamen", category: "product" },
{} as any,
);
const second = await tool.execute!(
{ reason: "humano", summary: "pregunta otra vez cuándo lo llaman", category: "product" },
{} as any,
);
const third = await tool.execute!(
{ reason: "humano", summary: "insiste con la misma llamada", category: "product" },
{} 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);
expect(list[0].summary).toContain("quiere que lo llamen");
expect(list[0].summary).toContain("pregunta otra vez cuándo lo llaman");
expect(list[0].summary).toContain("insiste con la misma llamada");

const conv = await new ConversationsRepo(new Db(env.DB)).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("opens a fresh ticket after the previous one was resolved", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("ok"));
const envNotify = {
...env,
RESEND_API_KEY: undefined,
TELEGRAM_BOT_TOKEN: "123:abc",
OWNER_TELEGRAM_CHAT_ID: "999",
};
const tool = handoffHumanTool(envNotify, () => convId);
const first = await tool.execute!(
{ reason: "humano", summary: "primera solicitud", category: "other" },
{} as any,
);
const firstId = (first as { ticketId: string }).ticketId;
await tickets.resolve(firstId, "dueno@negocio.com");

const second = await tool.execute!(
{ reason: "humano", summary: "otra solicitud ya resuelta la anterior", category: "other" },
{} 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(open[0].summary).toContain("otra solicitud");

const conv = await new ConversationsRepo(new Db(env.DB)).getById(convId);
expect(conv?.open_ticket_id).toBe(secondId);

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