diff --git a/src/agent/tools.test.ts b/src/agent/tools.test.ts index 739cb29..a4c1970 100644 --- a/src/agent/tools.test.ts +++ b/src/agent/tools.test.ts @@ -1,6 +1,10 @@ import test from "node:test"; import assert from "node:assert"; -import { agentTools } from "./tools.js"; +import { RunContext } from "@openai/agents"; +import { z } from "zod"; +import { agentTools, rgCreateNeed, rgPreregisterDonation, rgRegisterByPhone } from "./tools.js"; +import type { AgentContext } from "./context.js"; +import type { Account } from "../domain/account.js"; test("agentTools registra las tools de donación", () => { const names = new Set(agentTools.map((t: any) => t.name)); @@ -14,7 +18,196 @@ test("agentTools registra las tools de donación", () => { assert.ok(names.has("rg_record_inventory_entry")); }); +// Lookahead `(?=`/`(?!` o lookbehind `(?<=`/`(?` no cuenta. +const LOOKAROUND = /\(\? + key === "pattern" && typeof value === "string" + ? LOOKAROUND.test(value) + ? [value] + : [] + : patternsWithLookaround(value), + ); +} + +test("patternsWithLookaround solo mira los valores de `pattern`", () => { + // Lo que genera zod con `.email()` debe detectarse. + assert.ok(patternsWithLookaround(z.toJSONSchema(z.object({ email: z.string().email() }))).length > 0); + + const schema = { + type: "object", + description: "Texto libre con (?=esto) que no es un pattern", + properties: { + year: { anyOf: [{ type: "string", pattern: "^(?\\d{4})$" }, { type: "null" }] }, + tags: { type: "array", items: { type: "string", pattern: "(? { + // Un `pattern` con `(?!…)`/`(?=…)` (lo que genera zod con `.email()`) hace que la + // Responses API devuelva `incomplete: max_output_tokens` sin output para TODO el + // set de tools: el agente agota maxTurns y el bot deja de contestar a todos. + for (const t of agentTools as any[]) { + assert.deepStrictEqual(patternsWithLookaround(t.parameters), [], `${t.name} tiene un pattern con lookaround`); + } +}); + test("rg_record_inventory_entry se documenta como acción de staff, no de donación", () => { const inv = agentTools.find((t: any) => t.name === "rg_record_inventory_entry") as any; assert.match(inv.description, /rg_preregister_donation|donar|donaci/i); }); + +const account: Account = { + id: "acc-1", + channel: "telegram", + emergencySlug: "sismo-2026", + apiToken: "rg_live_test", + telegramBotToken: "bot-1", +}; + +const EMERGENCY_ID = "11111111-1111-4111-8111-111111111111"; +const RESOURCE_ID = "22222222-2222-4222-8222-222222222222"; + +/** Contexto de agente con un apiClient falso que registra las peticiones. */ +function fakeContext() { + const requests: Array<{ method: string; path: string; body: any }> = []; + const context = { + channel: "telegram", + chatId: "chat-1", + account, + user: {}, + authenticated: true, + verifiedPhone: "+34600000000", + apiClient: { + request: async (method: string, path: string, body?: unknown) => { + requests.push({ method, path, body }); + return { id: "creado" }; + }, + }, + } as unknown as AgentContext; + return { runContext: new RunContext(context), requests }; +} + +/** Sustituye fetch (lo usa TrustedAuthClient) y cuenta las llamadas. */ +async function withMockedFetch(response: () => Response, run: (calls: { count: number }) => Promise) { + const original = globalThis.fetch; + const calls = { count: 0 }; + globalThis.fetch = (async () => { + calls.count++; + return response(); + }) as unknown as typeof fetch; + try { + await run(calls); + } finally { + globalThis.fetch = original; + } +} + +const donationItems = [{ name: "Agua", quantity: 10, category: "water" }]; + +test("validación de email en las tools", async (t) => { + await t.test("rg_register_by_phone pide otro email si el formato no es válido, sin llamar a la API", async () => { + const { runContext } = fakeContext(); + await withMockedFetch( + () => new Response("{}", { status: 201 }), + async (calls) => { + const result = await rgRegisterByPhone.invoke( + runContext, + JSON.stringify({ name: "Ana Pérez", email: "ana@correo", acceptedTerms: true }), + ); + assert.match(String(result), /El email 'ana@correo' no parece válido/); + assert.match(String(result), /escriba de nuevo/); + assert.strictEqual(calls.count, 0, "no debería llamar a register-by-phone"); + }, + ); + }); + + await t.test("rg_register_by_phone pide revisar los datos si la API responde 400", async () => { + const { runContext } = fakeContext(); + await withMockedFetch( + () => new Response(JSON.stringify({ statusCode: 400, message: ["email must be an email"] }), { status: 400 }), + async () => { + const result = await rgRegisterByPhone.invoke( + runContext, + JSON.stringify({ name: "Ana Pérez", email: "ana@correo.com", acceptedTerms: true }), + ); + assert.match(String(result), /no parece válido/); + assert.doesNotMatch(String(result), /An error occurred/); + }, + ); + }); + + await t.test("rg_preregister_donation pide revisar u omitir un donorEmail no válido, sin llamar a la API", async () => { + const { runContext, requests } = fakeContext(); + const result = await rgPreregisterDonation.invoke( + runContext, + JSON.stringify({ + emergencyId: EMERGENCY_ID, + targetResourceId: RESOURCE_ID, + donorName: "Ana Pérez", + donorEmail: "ana(at)correo.com", + items: donationItems, + }), + ); + assert.match(String(result), /El email 'ana\(at\)correo\.com' no parece válido/); + assert.match(String(result), /omit/); + assert.strictEqual(requests.length, 0); + }); + + await t.test("rg_preregister_donation envía el donorEmail válido recortado", async () => { + const { runContext, requests } = fakeContext(); + await rgPreregisterDonation.invoke( + runContext, + JSON.stringify({ + emergencyId: EMERGENCY_ID, + targetResourceId: RESOURCE_ID, + donorName: "Ana Pérez", + donorEmail: " ana@correo.com ", + items: donationItems, + }), + ); + assert.strictEqual(requests.length, 1); + assert.strictEqual(requests[0]!.path, `/emergencies/${EMERGENCY_ID}/donation-intakes`); + assert.strictEqual(requests[0]!.body.donorEmail, "ana@correo.com"); + }); + + await t.test("rg_create_need rechaza un author.email no válido, sin llamar a la API", async () => { + const { runContext, requests } = fakeContext(); + const result = await rgCreateNeed.invoke( + runContext, + JSON.stringify({ + emergencyId: EMERGENCY_ID, + title: "Agua potable", + location: { address: "Plaza Mayor", latitude: 10.5, longitude: -66.9 }, + priority: "high", + items: donationItems, + author: { name: "Ana", email: "ana@" }, + }), + ); + assert.match(String(result), /El email 'ana@' no parece válido/); + assert.strictEqual(requests.length, 0); + }); + + await t.test("rg_create_need envía el author.email válido recortado y conserva el resto del author", async () => { + const { runContext, requests } = fakeContext(); + await rgCreateNeed.invoke( + runContext, + JSON.stringify({ + emergencyId: EMERGENCY_ID, + title: "Agua potable", + location: { address: "Plaza Mayor", latitude: 10.5, longitude: -66.9 }, + priority: "high", + items: donationItems, + author: { name: "Ana", email: " ana@correo.com " }, + }), + ); + assert.strictEqual(requests.length, 1); + assert.deepStrictEqual(requests[0]!.body.author, { name: "Ana", email: "ana@correo.com" }); + }); +}); diff --git a/src/agent/tools.ts b/src/agent/tools.ts index c3d13c8..d13d7ca 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -5,8 +5,10 @@ import { TrustedAuthClient, PhoneNotFoundError, EmailAlreadyExistsError, + InvalidRegistrationDataError, } from "../infrastructure/responsegrid/trusted-auth-client.js"; import { ApiClient } from "../infrastructure/responsegrid/api-client.js"; +import { Email } from "../domain/email.js"; import { toToolJson } from "./tool-result.js"; const trustedAuthClient = new TrustedAuthClient(); @@ -87,9 +89,17 @@ const supplyLineSchema = z.object({ .describe("Fecha de caducidad o frescura en formato YYYY-MM-DD, si aplica."), }); +// Fuente única para los campos email de las tools: solo `z.string()`. Con `.email()` zod emite un +// `pattern` con lookahead que el modo strict de OpenAI rechaza (y deja mudo al bot), y en los campos +// opcionales el SDK además elimina format/pattern sin avisar. Por eso el formato se valida en +// `execute` con el value object Email. +function emailField(description: string) { + return z.string().describe(description); +} + const authorSchema = z.object({ name: z.string().optional(), - email: z.string().email().optional(), + email: emailField("Email de contacto del autor, si lo da.").optional(), phone: z.string().optional(), note: z.string().optional(), verified: z.boolean().optional(), @@ -153,6 +163,35 @@ function requireAuth(context: AgentContext): void { } } +function invalidEmailMessage(raw: string, followUp: string): string { + return `El email '${raw}' no parece válido. ${followUp}`; +} + +/** + * Normaliza con el value object Email un email opcional que llega del modelo: `{ value }` con el + * email recortado (vacío si no viene) o `{ error }` con el mensaje para el agente si no es válido. + */ +function normalizeOptionalEmail(raw: string | undefined): { value?: string; error?: string } { + if (!raw) { + return {}; + } + const email = Email.tryCreate(raw); + return email + ? { value: email.value } + : { error: invalidEmailMessage(raw, "Pide al usuario que lo revise o que lo omita si prefiere no darlo.") }; +} + +/** Valida y normaliza `author.email`: devuelve el mensaje para el agente o el input con el email recortado. */ +function withNormalizedAuthorEmail( + input: T, +): T | string { + const { value, error } = normalizeOptionalEmail(input.author?.email); + if (error) { + return error; + } + return input.author ? { ...input, author: { ...input.author, email: value } } : input; +} + export const rgGetApiIdentity = tool({ name: "rg_get_api_identity", description: @@ -351,8 +390,12 @@ export const rgRegisterResource = tool({ execute: async (input, runContext?: RunContext) => { const context = getContext(runContext); requireAuth(context); - const emergencyId = await resolveEmergencyId(context, input); - const { emergencyId: _eid, emergencySlug: _slug, ...payload } = input; + const normalized = withNormalizedAuthorEmail(input); + if (typeof normalized === "string") { + return normalized; + } + const emergencyId = await resolveEmergencyId(context, normalized); + const { emergencyId: _eid, emergencySlug: _slug, ...payload } = normalized; const result = await context.apiClient.request( "POST", `/emergencies/${emergencyId}/resources`, @@ -432,17 +475,21 @@ export const rgPreregisterDonation = tool({ .describe("ID del punto de acopio destino donde la persona entregará la donación."), donorName: z.string().min(2).describe("Nombre de quien dona."), donorPhone: z.string().optional().describe("Teléfono de contacto del donante, si lo da."), - donorEmail: z.string().email().optional().describe("Email del donante, si lo da."), + donorEmail: emailField("Email del donante, si lo da.").optional(), items: z.array(supplyLineSchema).min(1), }), execute: async (input, runContext?: RunContext) => { const context = getContext(runContext); + const donorEmail = normalizeOptionalEmail(input.donorEmail); + if (donorEmail.error) { + return donorEmail.error; + } const emergencyId = await resolveEmergencyId(context, input); const { emergencyId: _eid, emergencySlug: _slug, ...payload } = input; const result = await context.apiClient.request( "POST", `/emergencies/${emergencyId}/donation-intakes`, - payload, + { ...payload, donorEmail: donorEmail.value }, ); return asPrettyJson(result); }, @@ -463,8 +510,12 @@ export const rgSubmitOffer = tool({ execute: async (input, runContext?: RunContext) => { const context = getContext(runContext); requireAuth(context); - const emergencyId = await resolveEmergencyId(context, input); - const { emergencyId: _eid, emergencySlug: _slug, ...payload } = input; + const normalized = withNormalizedAuthorEmail(input); + if (typeof normalized === "string") { + return normalized; + } + const emergencyId = await resolveEmergencyId(context, normalized); + const { emergencyId: _eid, emergencySlug: _slug, ...payload } = normalized; const result = await context.apiClient.request( "POST", `/emergencies/${emergencyId}/offers`, @@ -613,8 +664,12 @@ export const rgCreateNeed = tool({ execute: async (input, runContext?: RunContext) => { const context = getContext(runContext); requireAuth(context); - const emergencyId = await resolveEmergencyId(context, input); - const { emergencyId: _eid, emergencySlug: _slug, ...payload } = input; + const normalized = withNormalizedAuthorEmail(input); + if (typeof normalized === "string") { + return normalized; + } + const emergencyId = await resolveEmergencyId(context, normalized); + const { emergencyId: _eid, emergencySlug: _slug, ...payload } = normalized; const result = await context.apiClient.request( "POST", `/emergencies/${emergencyId}/needs`, @@ -709,7 +764,9 @@ export const rgRegisterByPhone = tool({ "Da de alta una cuenta nueva de ResponseGrid a partir del teléfono ya verificado del usuario, cuando rg_request_user_login ha respondido que no existe cuenta. Requiere que el usuario haya confirmado explícitamente que acepta los términos y la política de privacidad antes de llamarla.", parameters: z.object({ name: z.string().min(2).describe("Nombre completo del usuario."), - email: z.string().email().describe("Email del usuario."), + // Sin `.email()`: el modo strict de OpenAI rechaza los `pattern` con lookahead que genera zod + // (y con ellos TODO el set de tools). El formato se valida en `execute` con el value object Email. + email: emailField("Email del usuario."), acceptedTerms: z .boolean() .describe("true solo si el usuario ha confirmado explícitamente que acepta términos y privacidad."), @@ -725,11 +782,16 @@ export const rgRegisterByPhone = tool({ return "No puedo crear la cuenta sin que el usuario confirme explícitamente que acepta los términos y la política de privacidad. Pídeselo de nuevo antes de reintentar."; } + const email = Email.tryCreate(input.email); + if (!email) { + return invalidEmailMessage(input.email, "Pide al usuario que lo revise y lo escriba de nuevo."); + } + try { const result = await trustedAuthClient.registerByPhone(context.account, { phone: context.verifiedPhone, name: input.name, - email: input.email, + email: email.value, }); applyUserLogin(context, result.accessToken); return `Cuenta creada y autenticada con éxito como ${result.user.name} (${result.user.email}).`; @@ -737,6 +799,9 @@ export const rgRegisterByPhone = tool({ if (error instanceof EmailAlreadyExistsError) { return "Ya existe una cuenta de ResponseGrid con ese email. Pide al usuario un email distinto."; } + if (error instanceof InvalidRegistrationDataError) { + return `ResponseGrid ha rechazado los datos de registro: el email '${email.value}' no parece válido o algún otro dato es incorrecto. Pide al usuario que revise su nombre y su email y los escriba de nuevo.`; + } throw error; } }, diff --git a/src/domain/email.test.ts b/src/domain/email.test.ts new file mode 100644 index 0000000..9afc0f5 --- /dev/null +++ b/src/domain/email.test.ts @@ -0,0 +1,25 @@ +import test from "node:test"; +import assert from "node:assert"; +import { Email } from "./email.js"; + +test("Email", async (t) => { + await t.test("acepta emails con formato válido", () => { + for (const raw of ["ana@x.com", "ana.perez+rg@correo.gob.ve", "a_b-c@sub.dominio.org"]) { + const email = Email.tryCreate(raw); + assert.ok(email, `debería aceptar ${raw}`); + assert.strictEqual(email.value, raw); + } + }); + + await t.test("recorta los espacios de alrededor", () => { + const email = Email.tryCreate(" ana@x.com \n"); + assert.strictEqual(email?.value, "ana@x.com"); + assert.strictEqual(String(email), "ana@x.com"); + }); + + await t.test("rechaza emails con formato no válido", () => { + for (const raw of ["", " ", "ana", "ana@", "@x.com", "ana@x", "ana@x.c", "ana x@y.com", "ana@@x.com", "ana@x .com"]) { + assert.strictEqual(Email.tryCreate(raw), undefined, `debería rechazar '${raw}'`); + } + }); +}); diff --git a/src/domain/email.ts b/src/domain/email.ts new file mode 100644 index 0000000..26fde07 --- /dev/null +++ b/src/domain/email.ts @@ -0,0 +1,18 @@ +// Formato mínimo "algo@dominio.tld". Deliberadamente simple y sin lookahead: la validación +// exhaustiva la hace la API de ResponseGrid (@IsEmail); aquí solo evitamos enviarle un email roto. +const EMAIL_FORMAT = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; + +/** Value object: email con formato válido y sin espacios alrededor. */ +export class Email { + private constructor(readonly value: string) {} + + /** Crea un Email si `raw` (recortado) tiene formato válido; si no, devuelve undefined. */ + static tryCreate(raw: string): Email | undefined { + const trimmed = raw.trim(); + return EMAIL_FORMAT.test(trimmed) ? new Email(trimmed) : undefined; + } + + toString(): string { + return this.value; + } +} diff --git a/src/infrastructure/responsegrid/trusted-auth-client.test.ts b/src/infrastructure/responsegrid/trusted-auth-client.test.ts index 9041512..5eb4ae2 100644 --- a/src/infrastructure/responsegrid/trusted-auth-client.test.ts +++ b/src/infrastructure/responsegrid/trusted-auth-client.test.ts @@ -4,6 +4,7 @@ import { TrustedAuthClient, PhoneNotFoundError, EmailAlreadyExistsError, + InvalidRegistrationDataError, } from "./trusted-auth-client.js"; import type { Account } from "../../domain/account.js"; @@ -81,6 +82,27 @@ test("TrustedAuthClient", async (t) => { ); }); + await t.test("registerByPhone lanza InvalidRegistrationDataError en 400, sin incrustar el body", async () => { + await withMockedFetch( + (async () => + new Response(JSON.stringify({ statusCode: 400, message: ["email must be an email"] }), { + status: 400, + })) as unknown as typeof fetch, + async () => { + const client = new TrustedAuthClient("https://api.test"); + await assert.rejects( + () => client.registerByPhone(account, { phone: "+34600000000", name: "Ana", email: "ana@x" }), + (error: unknown) => { + assert.ok(error instanceof InvalidRegistrationDataError); + // El body crudo se logea aparte; el error (que puede llegar al agente) no lo lleva. + assert.doesNotMatch(error.message, /email must be an email/); + return true; + }, + ); + }, + ); + }); + await t.test("registerByPhone devuelve el token en éxito", async () => { await withMockedFetch( (async () => diff --git a/src/infrastructure/responsegrid/trusted-auth-client.ts b/src/infrastructure/responsegrid/trusted-auth-client.ts index 0ce7ce4..e53ac1c 100644 --- a/src/infrastructure/responsegrid/trusted-auth-client.ts +++ b/src/infrastructure/responsegrid/trusted-auth-client.ts @@ -14,6 +14,8 @@ export interface TrustedAuthResult { export class PhoneNotFoundError extends Error {} export class EmailAlreadyExistsError extends Error {} +/** ResponseGrid rechaza los datos del alta (400 de validación, p. ej. email mal formado). */ +export class InvalidRegistrationDataError extends Error {} export class TrustedAuthClient { constructor(private readonly baseUrl: string = env.apiBaseUrl ?? "") {} @@ -57,6 +59,12 @@ export class TrustedAuthClient { throw new EmailAlreadyExistsError(`Ya existe una cuenta con el email ${input.email}`); } + if (response.status === 400) { + // Igual que en api-client: el body crudo se logea aparte y NO va en el error (el agente podría parafrasearlo). + console.error(`[trusted-auth] register-by-phone -> 400 :: ${(await response.text()).slice(0, 500)}`); + throw new InvalidRegistrationDataError("register-by-phone rechazó los datos de registro (400)."); + } + if (!response.ok) { console.error(`[trusted-auth] register-by-phone -> ${response.status} :: ${(await response.text()).slice(0, 500)}`); throw new Error(`register-by-phone falló con estado ${response.status}.`);