-
Notifications
You must be signed in to change notification settings - Fork 26
fix(citas): agendar funciona end-to-end — Cal.com v2 + fecha real en el prompt #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,7 @@ export interface SystemPromptInput { | |
| tone?: string; // owner-chosen tone (e.g. "cálido y cercano") | ||
| extraEscalationKeywords?: string[]; // extra words that trigger a human handoff | ||
| lessons?: string[]; // flywheel: rules distilled from owner takeovers | ||
| today?: string; // fecha/hora actual en la zona del negocio | ||
| } | ||
|
|
||
| const TEMPLATE = `<output_language> | ||
|
|
@@ -33,6 +34,8 @@ cliente con eficiencia y calidez, sin inventar nunca. Conoces este negocio. | |
| Si una pregunta no tiene respuesta en lo que sabes, escalas a un humano. | ||
| </role> | ||
|
|
||
| {{CONTEXTO_TEMPORAL}} | ||
|
|
||
| <business_context> | ||
| {{BUSINESS_CONTEXT}} | ||
| </business_context> | ||
|
|
@@ -122,7 +125,16 @@ ${lessons.map((l) => `- ${l}`).join("\n")} | |
| </lecciones_aprendidas>` | ||
| : ""; | ||
|
|
||
| const contextoTemporal = input.today | ||
| ? `<contexto_temporal> | ||
| Hoy es ${input.today}. Tu conocimiento de entrenamiento tiene OTRA fecha — ignórala. | ||
| Usa SIEMPRE esta fecha real para interpretar "hoy", "mañana", "el viernes", etc., | ||
| y para toda fecha que pases a las tools (citas, horarios). | ||
| </contexto_temporal>` | ||
| : ""; | ||
|
|
||
| return TEMPLATE | ||
| .replaceAll("{{CONTEXTO_TEMPORAL}}", contextoTemporal) | ||
| .replaceAll("{{LANGUAGE}}", input.language) | ||
| .replaceAll("{{BOT_NAME}}", input.botName) | ||
| .replaceAll("{{BUSINESS_NAME}}", input.businessName) | ||
|
|
@@ -141,6 +153,24 @@ export interface SystemPromptOverrides { | |
| lessons?: string[]; | ||
| } | ||
|
|
||
| /** Fecha/hora actual legible + ISO en la zona del negocio (ancla "hoy"/"mañana"). */ | ||
| export function currentDateLine(timeZone: string): string { | ||
| const now = new Date(); | ||
| const legible = new Intl.DateTimeFormat("es-MX", { | ||
| timeZone, | ||
| dateStyle: "full", | ||
| timeStyle: "short", | ||
| }).format(now); | ||
| // en-CA formatea YYYY-MM-DD, útil como fecha ISO para las tools. | ||
| const iso = new Intl.DateTimeFormat("en-CA", { | ||
| timeZone, | ||
| year: "numeric", | ||
| month: "2-digit", | ||
| day: "2-digit", | ||
| }).format(now); | ||
| return `${legible} (fecha ISO: ${iso}, zona horaria: ${timeZone})`; | ||
| } | ||
|
|
||
| export function systemPromptFromEnv( | ||
| env: Env, | ||
| toolNames: string[], | ||
|
|
@@ -158,5 +188,6 @@ export function systemPromptFromEnv( | |
| tone: overrides?.tone, | ||
| extraEscalationKeywords: overrides?.extraEscalationKeywords, | ||
| lessons: overrides?.lessons, | ||
| today: currentDateLine((env.CALCOM_TIMEZONE || "").trim() || "America/Mexico_City"), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Description: Verify DEFAULT_TZ value and current imports in system-prompt.ts
set -euo pipefail
fd -e ts . src/integrations --exec cat -n {}
echo "---"
fd system-prompt.ts src --exec cat -n {}Repository: santmun/forja Length of output: 15480 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "Top-level files:"
fd -e ts -e toml -e json -e yaml -e yml . src . --max-depth 3
echo "--- imports containing calcom:"
rg -n 'from ["'\''](\./|../)\.?integrations/.*calcom|calcomTimeZone|DEFAULT_TZ|systemPromptFromEnv' src --glob '*.ts'Repository: santmun/forja Length of output: 6750 Reuse
🤖 Prompt for AI Agents |
||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,38 +1,78 @@ | ||
| import { tool } from "ai"; | ||
| import { z } from "zod"; | ||
| import type { Env } from "../env"; | ||
| import { | ||
| calcomConfigured, | ||
| calcomTimeZone, | ||
| createBooking, | ||
| getAvailableSlots, | ||
| resolveEventTypeId, | ||
| todayInTz, | ||
| } from "../integrations/calcom"; | ||
|
|
||
| const CALCOM_API = "https://api.cal.com/v1"; | ||
|
|
||
| // El eventTypeId y la zona horaria se resuelven SIEMPRE en el servidor | ||
| // (CALCOM_EVENT_TYPE_ID / CALCOM_EVENT_TYPES / CALCOM_TIMEZONE): el modelo no | ||
| // conoce esos ids y no debe inventarlos. | ||
| export function scheduleAppointmentTool(env: Env, _getConversationId: () => string | null) { | ||
| return tool({ | ||
| description: | ||
| "Agenda una cita usando Cal.com. Necesitas eventTypeId (el dueño lo configura en Cal.com), fecha/hora, nombre y email del cliente.", | ||
| "Consulta horarios libres y agenda citas reales en el calendario del negocio (Cal.com). " + | ||
| "Para ver horarios disponibles de un día: pasa solo `date` (YYYY-MM-DD). " + | ||
| "Para reservar: pasa `startTime` (ISO con offset de la zona del negocio, ej. 2026-08-03T15:00:00-06:00), " + | ||
| "`attendeeName` y `attendeeEmail` — idealmente un `startTime` que venga de los horarios consultados. " + | ||
| "Si el negocio maneja varios tipos de cita, indica `service` con el nombre del servicio.", | ||
| inputSchema: z.object({ | ||
| eventTypeId: z.number().int().describe("Cal.com event type ID"), | ||
| startTime: z.string().describe("ISO datetime, e.g. 2026-06-01T17:00:00Z"), | ||
| attendeeName: z.string(), | ||
| attendeeEmail: z.string().email(), | ||
| date: z.string().optional().describe("YYYY-MM-DD para consultar horarios libres de ese día"), | ||
| startTime: z.string().optional().describe("ISO datetime con offset para reservar, ej. 2026-08-03T15:00:00-06:00"), | ||
|
Comment on lines
+25
to
+26
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Enforce The past-date guard at Line 43 compares Add a regex constraint to 🐛 Proposed fix inputSchema: z.object({
- date: z.string().optional().describe("YYYY-MM-DD para consultar horarios libres de ese día"),
- startTime: z.string().optional().describe("ISO datetime con offset para reservar, ej. 2026-08-03T15:00:00-06:00"),
+ date: z
+ .string()
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
+ .optional()
+ .describe("YYYY-MM-DD para consultar horarios libres de ese día"),
+ startTime: z
+ .string()
+ .regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
+ .optional()
+ .describe("ISO datetime con offset para reservar, ej. 2026-08-03T15:00:00-06:00"),Also applies to: 41-49 🤖 Prompt for AI Agents |
||
| attendeeName: z.string().optional(), | ||
| attendeeEmail: z.string().email().optional(), | ||
| service: z.string().optional().describe("nombre del servicio/tipo de cita solicitado"), | ||
| notes: z.string().optional(), | ||
| }), | ||
| execute: async ({ eventTypeId, startTime, attendeeName, attendeeEmail, notes }) => { | ||
| if (!env.CALCOM_API_KEY) return { error: "calcom_not_configured" as const }; | ||
| try { | ||
| const res = await fetch(`${CALCOM_API}/bookings?apiKey=${env.CALCOM_API_KEY}`, { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ | ||
| eventTypeId, | ||
| start: startTime, | ||
| responses: { name: attendeeName, email: attendeeEmail, notes: notes ?? "" }, | ||
| }), | ||
| execute: async ({ date, startTime, attendeeName, attendeeEmail, service, notes }) => { | ||
| if (!calcomConfigured(env)) return { error: "calcom_not_configured" as const }; | ||
| const eventTypeId = resolveEventTypeId(env, service); | ||
| if (eventTypeId == null) return { error: "calcom_not_configured" as const }; | ||
| const timeZone = calcomTimeZone(env); | ||
|
|
||
| // Guardia anti-fecha-fantasma: los LLM no saben qué día es hoy y suelen | ||
| // proponer fechas de su época de entrenamiento. YYYY-MM-DD compara bien | ||
| // como string. | ||
| const today = todayInTz(timeZone); | ||
| const requestedDay = date ?? startTime?.slice(0, 10); | ||
| if (requestedDay && requestedDay < today) { | ||
| return { | ||
| error: "date_in_past" as const, | ||
| today, | ||
| hint: `Hoy es ${today}. Recalcula la fecha pedida por el cliente a partir de hoy y reintenta.`, | ||
| }; | ||
| } | ||
|
|
||
| // Reservar: requiere hora exacta + datos del cliente. | ||
| if (startTime && attendeeName && attendeeEmail) { | ||
| const r = await createBooking(env, { | ||
| eventTypeId, | ||
| start: startTime, | ||
| name: attendeeName, | ||
| email: attendeeEmail, | ||
| timeZone, | ||
| notes, | ||
| }); | ||
| if (!res.ok) return { error: "calcom_failed" as const, status: res.status }; | ||
| const body = (await res.json()) as any; | ||
| return { bookingId: body.id, status: body.status }; | ||
| } catch (e: any) { | ||
| return { error: "transient" as const, message: String(e?.message ?? e) }; | ||
| if (!r.ok) return { error: "calcom_failed" as const, reason: r.reason }; | ||
| return { booked: true, bookingId: r.bookingId, status: r.status, start: r.start ?? startTime }; | ||
| } | ||
|
|
||
| // Consultar horarios libres de un día. | ||
| if (date) { | ||
| const r = await getAvailableSlots(env, eventTypeId, date, timeZone); | ||
| if (!r.ok) return { error: "calcom_failed" as const, reason: r.reason }; | ||
| return { date, timeZone, slots: r.slots.slice(0, 12) }; | ||
| } | ||
|
|
||
| return { | ||
| error: "missing_params" as const, | ||
| hint: "Pasa `date` para ver horarios, o `startTime` + `attendeeName` + `attendeeEmail` para reservar.", | ||
| }; | ||
| }, | ||
| }); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🌐 Web query:
Does the Cal.com API v2 /v2/bookings error response ever include the submitted attendee email or name in the error message?💡 Result:
The Cal.com API v2 error response format does not explicitly include a dedicated field for the submitted attendee email [1][2][3]. According to the official documentation, Cal.com API v2 uses a consistent, structured error response that contains a status, code, and message [1][2][3]. The structure is as follows: { "status": "error", "error": { "code": "SOME_ERROR_CODE", "message": "A description of the error" } } While the error message string (in the "message" field) could technically contain dynamic content depending on the specific validation failure—such as missing required fields or invalid input—there is no systematic inclusion of the submitted attendee email in the standard error response schema [1][4][3]. In practice, error messages are typically generic descriptions of the failed validation or server-side issue [1][5][3].
Citations:
/v2/bookingsendpoint, the API returns a400 BAD_REQUESTerror with the message: responses - {title}error_required_field calcom/cal.diy#24851🏁 Script executed:
Repository: santmun/forja
Length of output: 37450
🌐 Web query:
Cal.com API v2 bookings validation error email name validation message💡 Result:
When using the Cal.com API v2 to create bookings (POST /v2/bookings), validation errors—such as those involving attendee information (name, email) or other required fields—are typically returned as 400 Bad Request responses containing a structured error object [1][2]. Key details regarding validation and error messages: 1. Structured Error Format: API v2 provides structured error responses [1]. If a validation fails, the response will generally have a status of "error" and include an error code (e.g., BAD_REQUEST) along with specific details about which fields failed validation [1][2][3]. 2. Attendee Validation: - Name: Recent updates to the API have improved name validation to be more flexible, for instance by making the last name optional while still requiring a valid, non-empty first name [4]. - Contact Methods: The API requires at least one valid contact method—either an email or a phone number [5][6]. If an email is provided, it must follow a valid email format [5][6]. 3. Required Fields: If an event type has mandatory booking fields defined in its configuration, failing to provide values for these in the bookingFieldsResponses object will trigger a validation error [7]. Recent efforts have been made to improve these error messages to explicitly name the missing fields rather than returning generic or 500-level errors [7][8]. 4. Troubleshooting Common Errors: - 400 Bad Request: Usually indicates malformed JSON, missing required fields (like attendee information or timeZone), or invalid data types (e.g., sending an integer as a string) [2][9][3]. - 500 Internal Server Error: While the API aims to return 400s for validation issues, some complex edge cases (like lack of availability or deep configuration issues) have historically resulted in 500 errors; these are actively being tracked and refactored for better clarity [2][10][8]. For the most accurate debugging, ensure your request includes the correct cal-api-version header (e.g., 2024-08-13) and check the "details" or "errors" array in the JSON response body for the specific field-level constraints [1][2][9][3].
Citations:
/v2/bookingsendpoint, the API returns a400 BAD_REQUESTerror with the message: responses - {title}error_required_field calcom/cal.diy#24851Avoid logging raw Cal.com response bodies.
createBookingsends attendeename,email, and optionalphoneNumber. Error logging uses(await res.text()), which can contain fields such as attendee data returned in Cal.com error messages. Log only the HTTP status and a truncated non-PII field such aserror.codeorerror.messageinstead of the full raw body. The success log also outputs the entire/v2/slotspayload; reduce it to a short summary such as the slot count.🤖 Prompt for AI Agents