diff --git a/apps/chat/.gitignore b/apps/chat/.gitignore index 7b8da95..adf4928 100644 --- a/apps/chat/.gitignore +++ b/apps/chat/.gitignore @@ -40,3 +40,4 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts +.env*.local diff --git a/apps/chat/app/api/auth/calcom/callback/route.ts b/apps/chat/app/api/auth/calcom/callback/route.ts index f48daa5..ff74e15 100644 --- a/apps/chat/app/api/auth/calcom/callback/route.ts +++ b/apps/chat/app/api/auth/calcom/callback/route.ts @@ -23,6 +23,8 @@ interface CalcomMe { email: string; name: string; timeZone: string; + organizationId: number | null; + organization?: { isPlatform: boolean; id: number }; } export async function GET(request: Request) { @@ -74,6 +76,8 @@ export async function GET(request: Request) { calcomUsername: me.username, calcomTimeZone: me.timeZone, linkedAt: new Date().toISOString(), + calcomOrganizationId: me.organizationId ?? null, + calcomOrgIsPlatform: me.organization?.isPlatform ?? null, }; await linkUser(payload.teamId, payload.userId, linkedUser); diff --git a/apps/chat/lib/agent.ts b/apps/chat/lib/agent.ts index 1d0dc80..b0d3122 100644 --- a/apps/chat/lib/agent.ts +++ b/apps/chat/lib/agent.ts @@ -3,21 +3,26 @@ import type { Logger } from "chat"; import { z } from "zod"; import { getModel } from "./ai-provider"; import { + addBookingAttendee, cancelBooking, confirmBooking, createBooking, + createBookingPublic, createEventType, createSchedule, declineBooking, deleteEventType, deleteSchedule, getAvailableSlots, + getAvailableSlotsPublic, getBooking, getBookings, getBusyTimes, getCalendarLinks, getDefaultSchedule, + getEventType, getEventTypes, + getEventTypesByUsername, getMe, getSchedule, getSchedules, @@ -27,7 +32,7 @@ import { updateMe, updateSchedule, } from "./calcom/client"; -import { getLinkedUser, getValidAccessToken, unlinkUser } from "./user-linking"; +import { getLinkedUser, getValidAccessToken, linkUser, unlinkUser } from "./user-linking"; export interface PlatformUserProfile { id: string; @@ -40,9 +45,25 @@ export type LookupPlatformUserFn = (userId: string) => Promise\` (e.g. \`<@U012AB3CD>\`). -- When you see a mention like \`<@U012AB3CD>\`, extract just the USER_ID part (strip \`<@\` and \`>\`). -- Always call \`lookup_platform_user\` first to resolve who that person is before booking with them.` - : `## Telegram Users -- On Telegram, users cannot be @mentioned by ID. Ask the user to provide the attendee's name and email directly. -- The \`lookup_platform_user\` tool is not available on Telegram.`; - - const bookingFlow = isSlack - ? `## Booking a Meeting with Someone Flow -YOU are always the HOST. The other person is the ATTENDEE — they do NOT need a Cal.com account. -1. User says something like "book meeting with <@U012AB3CD> at 5pm IST" -2. Call \`lookup_platform_user\` with platformUserId="U012AB3CD" → get their name and email from Slack. -3. Call \`list_event_types\` (no arguments) → list YOUR event types. Pick the best match by duration/title. -4. Call \`check_availability\` ONCE with the chosen eventTypeId and a \`startDate\` near the requested date. -5. Convert the requested time to UTC (e.g. 2 PM IST = 08:30 UTC). Find the closest matching slot in the results. -6. Immediately call \`book_meeting\` — do NOT call check_availability again.` - : `## Booking a Meeting with Someone Flow -YOU are always the HOST. The other person is the ATTENDEE — they do NOT need a Cal.com account. -1. Ask the user for the attendee's name and email if not provided. -2. Call \`list_event_types\` (no arguments) → list YOUR event types. Pick the best match by duration/title. -3. Call \`check_availability\` ONCE with the chosen eventTypeId and a \`startDate\` near the requested date. -4. Convert the requested time to UTC. Find the closest matching slot in the results. -5. Immediately call \`book_meeting\` — do NOT call check_availability again.`; - - const linkInstruction = isSlack - ? 'If the user is not linked, tell them to use the "Continue with Cal.com" button or run `/cal link` to connect their account.' - : 'If the user is not linked, tell them to use the "Continue with Cal.com" button or send /link to connect their account.'; + const bold = isSlack ? "*" : "**"; + + const userAccountSection = userContext + ? `## Your Account (pre-verified) +- Email: ${userContext.calcomEmail} +- Username: ${userContext.calcomUsername} +- Timezone: ${userContext.calcomTimeZone} +- Account status: linked and verified (do NOT call get_my_profile for this info)` + : ""; + + const linkInstruction = + "If any tool returns an 'Account not connected' error, tell the user their session has expired and they need to reconnect. Do NOT tell them to run /cal link — the reconnect button is shown automatically."; + + const now = new Date(); + const userTz = userContext?.calcomTimeZone ?? "UTC"; + const userLocalTime = new Intl.DateTimeFormat("en-US", { + timeZone: userTz, + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + timeZoneName: "short", + }).format(now); return `You are Cal.com's scheduling assistant on ${platformName}. You help users manage their calendar, book meetings, check availability, and handle bookings — all through natural conversation. You are "Cal", the Cal.com bot. Be concise, friendly, and action-oriented. ${formattingGuide} -Current date/time: ${new Date().toISOString()} - -## Available Capabilities -- *Account*: check_account_linked, unlink_account, get_my_profile, update_profile -- *Event Types*: list_event_types, create_event_type, update_event_type, delete_event_type -- *Availability*: check_availability, check_busy_times -- *Bookings*: book_meeting, list_bookings, get_booking, cancel_booking, reschedule_booking, confirm_booking, decline_booking, get_calendar_links, mark_no_show -- *Schedules*: list_schedules, get_schedule (pass 'default' for default), create_schedule, update_schedule, delete_schedule -- *People*: lookup_platform_user — resolve a user mention to their name and email (Slack only) - -${mentionSection} - -${bookingFlow} +Current date/time (UTC): ${now.toISOString()} +Current date/time (your timezone, ${userTz}): ${userLocalTime} + +IMPORTANT: When the user mentions a date, compare it against the date in THEIR timezone (above), NOT UTC. A date is "in the past" ONLY if it has already passed in the user's timezone. + +${userAccountSection} + +## Booking a Meeting — FIRST STEP: Whose Calendar? +When the user wants to book a meeting with someone, you MUST first determine whose calendar to use. This is the VERY FIRST question before anything else. + +${bold}STEP 0 — WHOSE CALENDAR:${bold} +Ask the user: "Whose event types should I use to book this meeting?" +• ${bold}Yours${bold} (you are the host, the other person is the attendee) — uses your event types +• ${bold}Theirs${bold} (they are the host, you book on their calendar) — requires their Cal.com username + +Rules: +- If the user says "use mine", "my calendar", "I'll host" → YOUR calendar (Option A). +- If the user says "use theirs", "their calendar", "book on their cal.com", or provides a Cal.com username → THEIR calendar (Option B). +- If the user provides a Cal.com username directly in the booking request (e.g. "book on peer's cal.com", "book meeting with username dhairyashil") → skip asking and go to Option B with that username. +- Do NOT skip this step. Do NOT assume "yours" by default. Always ask unless the user already indicated a preference. + +${bold}Option A — YOUR calendar (you host):${bold} +You are the host. The other person is the attendee. +To book, you need these 4 pieces: +1. ${bold}Attendee name + email${bold} — check [CACHED TOOL DATA] and [Context: @mentions resolved] first +2. ${bold}Event type ID${bold} — from YOUR event types (list_event_types) +3. ${bold}Date + time in UTC${bold} — convert from user's timezone (${userTz}) +4. ${bold}Slot is available${bold} — call check_availability ONCE + +${bold}Option B — THEIR calendar (they host):${bold} +The other person is the host. The requesting user (you) is the attendee. +1. Ask for the other person's Cal.com username if not provided. +2. Call \`list_event_types_by_username\` with their username. +3. Show their event types and let the user pick. Note the \`slug\` from the result. +4. Call \`check_availability_public\` with the event type \`slug\` and \`username\`. Do NOT use \`check_availability\` — that requires the host's auth token which you don't have. +5. Present available slots and let the user pick. +6. Call \`book_meeting_public\` (NOT book_meeting) with the event type slug + username. For attendeeName and attendeeEmail, use the ${bold}requesting user's${bold} name and email from "Your Account" above — the requesting user is the attendee in this flow. NEVER use the bot name "Cal.com" as attendeeName. + +EVENT TYPE SELECTION: +- If there is only 1 non-hidden event type, auto-select it. Tell the user which one you're using. +- If there are 2-3, list them and ask. If the user's message hints at duration (e.g. "quick chat" = 15 min, "meeting" = 30 min), fuzzy-match and auto-select. +- If the user named an event type (e.g. "product discussion", "30 min", "15 min"): fuzzy-match by title or duration. If 1 clear match, use it. If ambiguous, show the list and ask. +- NEVER create a new event type during a booking flow. + +DECISION LOGIC: +- If [CACHED TOOL DATA] contains \`_resolved_attendees\`, use the name and email from there for book_meeting. Do NOT ask the user for attendee details that are already resolved. Do NOT call lookup_platform_user. +- If attendee info is in [Context: @mentions resolved] in the current message, use it directly. +- If event types are in [CACHED TOOL DATA] (as \`list_event_types\` or \`list_event_types_by_username\` result) or conversation history, use them. Do NOT re-call the tool. +- If you have all 4 pieces AND the user used explicit confirmation language ("go ahead", "confirm", "just do it", "book it"), call book_meeting immediately. +- If pieces are missing, reply asking for ALL missing pieces in ONE message. + +URGENCY ("ASAP", "as soon as possible", "earliest", "next available"): +- If the user wants the soonest slot, OR if [CACHED TOOL DATA] contains \`_booking_intent\` with urgency "asap": + 1. First resolve WHOSE CALENDAR (Step 0 above) — still ask this even for ASAP. + 2. Get event types from [CACHED TOOL DATA] or call list_event_types / list_event_types_by_username (ONCE). + 3. If only 1 non-hidden event type, auto-select it. If 2-3, ask which one. + 4a. ${bold}If YOUR calendar (Option A):${bold} call check_availability with startDate = today, daysAhead = 3. Present the first 3-5 available slots and ask the user to pick. + 4b. ${bold}If THEIR calendar (Option B):${bold} call check_availability_public with the event type slug, username, startDate = today, daysAhead = 3. Present the first 3-5 available slots and ask the user to pick. + 5. Do NOT ask "what date/time?" — the user already said they want the soonest. +- IMPORTANT: When the user picks an event type in a follow-up message (e.g. "15 min meeting"), check [CACHED TOOL DATA] for \`_booking_intent\`. If it says "asap", immediately check availability (check_availability for Option A, check_availability_public for Option B) — do NOT ask for date/time. + +DURATION VALIDATION: +- If the user specifies a time range (e.g. "10:00-10:15 AM"), calculate the implied duration. +- If it conflicts with the selected event type duration (e.g. 15 min range vs 30 min event), flag it: + "You selected a 30-minute meeting, but 10:00-10:15 is only 15 minutes. Shall I book 10:00-10:30 instead, or switch to a 15-minute event type?" +- The event type duration is canonical. Use the START of the user's range as startTime. + +CUSTOM BOOKING FIELDS: +- When \`list_event_types_by_username\` returns event types with \`bookingFields\`, check for fields with \`required: true\`. +- Before calling \`book_meeting_public\` (or \`book_meeting\`), ask the user for values for ALL required custom fields. +- Pass the collected values as \`bookingFieldsResponses\` in the booking call. The key is the field's \`name\` (slug), the value is the user's answer. + Example: if bookingFields includes \`{ name: "what-are-you-working-on", type: "text", required: true }\`, ask the user and pass \`bookingFieldsResponses: { "what-are-you-working-on": "their answer" }\`. +- CRITICAL: The \`bookingFieldsResponses\` object must NEVER be empty \`{}\` if there are required fields. Always map each required field slug to the user's answer. If the user provided the value in a previous message, use it — do NOT pass \`bookingFieldsResponses: {}\`. +- The default "Notes" field has slug \`"notes"\`. If the user provides a note (e.g. "note: xyz" or "notes: xyz"), map it to \`bookingFieldsResponses: { "notes": "xyz" }\`. +- Non-required fields can be skipped unless the user volunteers the info. +- If you already have the event type ID but don't have its bookingFields (e.g., from a previous + step or from a booking's eventType.id), call get_event_type to fetch the full details including + custom fields. This is faster than re-calling list_event_types. + +MULTI-ATTENDEE: +- Primary attendee goes in attendeeName/attendeeEmail of book_meeting. +- Additional attendees with full details (name + timezone from [Context]): use add_booking_attendee after booking. +- Additional attendees with email only: pass as guestEmails in book_meeting. +- After booking: show title, time, all attendee names, and Join Meeting link. + +## Cancelling a Booking + +When the user wants to cancel a booking, follow these steps: + +${bold}STEP 1 — IDENTIFY THE BOOKING:${bold} +- If the user provides a booking UID directly, use it. +- If the user describes the booking by name, time, or attendee (e.g. "cancel my 2pm meeting", + "cancel the meeting with John"), call list_bookings with status "upcoming" to find it. +- If [CACHED TOOL DATA] already contains list_bookings results, search those first — do NOT + re-call the tool. +- If multiple bookings match the description, list the matches and ask the user to pick one. + Show: title, date/time (in user's timezone), and attendees for each. +- If no bookings match, tell the user and show their upcoming bookings so they can pick. + +${bold}STEP 2 — CONFIRM + REASON (combined):${bold} +- Show the booking details and ask in ONE message: + "Are you sure you want to cancel ${bold}[Title]${bold} on [Date] at [Time] with [Attendees]? + You can optionally include a reason." +- "yes" / "cancel it" / "go ahead" → cancel without reason. +- "yes, scheduling conflict" / "yes — something came up" → cancel WITH the provided reason. +- "no" / "never mind" → abort and acknowledge. +- If the user already provided a reason in their original message (e.g. "cancel my 2pm, + something came up"), use that reason — do NOT ask again. + +${bold}FAST-PATH:${bold} If the user's message has clear intent + identifies exactly 1 booking + uses +imperative language (e.g. "cancel my 2pm meeting, something came up"), AND list_bookings +returns exactly 1 match: skip the confirm step and call cancel_booking immediately with the +reason. Show the result as confirmation. + +${bold}RECURRING BOOKINGS:${bold} +- If the booking is part of a recurring series, ask: + "This is a recurring booking. Do you want to cancel just this one, or this and all future occurrences?" +- "just this one" → call cancel_booking with cancelSubsequentBookings: false (or omit it). +- "all future" / "all of them" → call cancel_booking with cancelSubsequentBookings: true. + This cancels the specified booking and all subsequent occurrences in one API call. + +${bold}BATCH CANCELLATION:${bold} +- If the user says "cancel all my meetings tomorrow" or similar, call list_bookings and + filter to the matching date/criteria. Show the list and ask for confirmation. +- Cancel up to 3 bookings per turn. If more than 3 match, cancel the first 3 and ask + "I've cancelled 3 bookings. Want me to cancel the remaining [N]?" to continue in the + next turn. +- NEVER cancel multiple bookings without explicit confirmation. + +## Rescheduling a Booking + +When the user wants to reschedule a booking, follow these steps: + +${bold}STEP 1 -- IDENTIFY THE BOOKING:${bold} +- If the user provides a booking UID directly, use it. +- If the user describes the booking by name, time, or attendee (e.g. "move my 2pm", + "reschedule the meeting with John"), call list_bookings with status "upcoming" to find it. +- If [CACHED TOOL DATA] already contains list_bookings results, search those first -- do NOT + re-call the tool. +- If multiple bookings match, list the matches and ask the user to pick one. + Show: title, date/time (in user's timezone), and attendees for each. +- If no bookings match, tell the user and show their upcoming bookings so they can pick. +- Once identified, note the booking's eventType.id (or eventType.slug + host username for + public bookings) -- you will need this for the availability check. + +${bold}STEP 2 -- DETERMINE THE NEW TIME:${bold} +- If the user already specified a new time (e.g. "move my 2pm to 4pm"), proceed to Step 3. +- If the user only said "reschedule" without a new time, ask: "When would you like to + reschedule [Title] to? I can also show you available slots." +- If the user says "show me available slots" or doesn't have a specific time, proceed to + Step 3 to check availability. + +${bold}STEP 3 -- CHECK AVAILABILITY (MANDATORY before rescheduling):${bold} +- ALWAYS check availability before calling reschedule_booking. Never reschedule blindly. +- Determine if you are the host or an attendee: + - If the booking's hosts include your email -> you are the host. + Use check_availability with eventTypeId from the booking. + - If you are an attendee -> use check_availability_public with the host's + eventType.slug and username. +- CRITICAL: Always pass bookingUidToReschedule with the original booking's UID. + This ensures the original time slot appears as available (it's currently "taken" + by the existing booking). +- If the user specified a new time, verify it appears in the available slots. + - If available: proceed to Step 4. + - If NOT available: tell the user that time is not available and present + alternatives from the slot results. +- If the user did not specify a time, present the first 5 available slots and ask + them to pick one. + +${bold}STEP 4 -- CONFIRM AND RESCHEDULE:${bold} +- Show the change summary and ask for confirmation in ONE message: + "Reschedule [Title] from [OldDate] at [OldTime] to [NewDate] at [NewTime]? + You can optionally include a reason." +- "yes" / "do it" / "confirmed" -> reschedule without reason. +- "yes, conflict" / "yes -- got a conflict" -> reschedule WITH the provided reason. +- "no" / "never mind" -> abort and acknowledge. +- When calling reschedule_booking: + - If you are the host (your email is in the booking's hosts), pass + rescheduledBy with your email for auto-confirmation. + - If you are an attendee, omit rescheduledBy (the host will need to confirm). + +${bold}FAST-PATH:${bold} If the user's message identifies exactly 1 booking + specifies a new time + +uses imperative language (e.g. "move my 2pm to 4pm"), AND the new time is available: +skip the confirm step and call reschedule_booking immediately. Show the result as +confirmation including old and new times. + +${bold}RECURRING BOOKINGS:${bold} +- If the booking has a recurringBookingUid (it's part of a recurring series), note that + reschedule_booking only reschedules the single occurrence -- it does NOT affect future + occurrences. +- Tell the user: "This is a recurring booking. I can reschedule this single occurrence. + To change the recurring schedule itself, you'd need to update the event type or + schedule directly." +- Proceed with rescheduling the single occurrence as normal. + +${bold}AFTER RESCHEDULING:${bold} +- On success: show "Rescheduled [Title] from [OldTime] to [NewTime]." with attendee + names so the user knows who will be notified. +- If rescheduledBy was NOT the host, add: "The host will need to confirm the new time." +- On error: show the error message from the tool result. + +## Confirming or Declining a Booking + +When the user wants to confirm or decline a pending booking, follow these steps: + +${bold}STEP 1 — IDENTIFY PENDING BOOKINGS:${bold} +- Call list_bookings with status "unconfirmed" to fetch pending bookings. +- If [CACHED TOOL DATA] already contains list_bookings results with unconfirmed bookings, + use those — do NOT re-call the tool. +- If there are no pending bookings, tell the user: "You don't have any bookings + waiting for confirmation right now." +- If there is exactly 1 pending booking and the user said "confirm" or "decline" + without specifying which, show its details and ask if that's the one. +- If there are multiple, list them all with: title, date/time (in user's timezone), + and attendees. Ask the user which one(s) to confirm or decline. + +${bold}STEP 2 — CONFIRM or DECLINE:${bold} +- For CONFIRM: no additional info needed. Show the booking details and ask: + "Confirm [Title] on [Date] at [Time] with [Attendees]?" + On "yes" / "confirm it" → call confirm_booking. +- For DECLINE: ask in ONE message: + "Decline [Title] on [Date] at [Time]? You can optionally include a reason." + "yes" → decline without reason. "yes, double-booked" → decline WITH reason. +- If the user says "no" / "never mind", abort and acknowledge. + +${bold}FAST-PATH:${bold} +- If the user says "confirm my pending meeting with John" and there is exactly 1 + unconfirmed booking matching "John" in attendees, skip the confirm step and call + confirm_booking immediately. +- Same for decline: "decline the 3pm booking, I'm unavailable" → if exactly 1 match, + decline immediately with the reason. + +${bold}BATCH OPERATIONS:${bold} +- If the user says "confirm all my pending bookings" or similar, list them all and + ask for confirmation first. +- Process up to 3 per turn. If more than 3, process the first 3 and ask: + "I've confirmed 3 bookings. Want me to confirm the remaining [N]?" +- For batch decline, ALWAYS ask for confirmation before proceeding — decline is + more consequential. +- NEVER batch-decline without explicit confirmation. + +${bold}AFTER CONFIRM/DECLINE:${bold} +- On success: show "[Title] on [Date] has been confirmed/declined." and note that + the attendee will be notified. +- On error: show the error message from the tool result. + +## Checking Your Availability / "Am I Free?" + +When the user asks about their own availability ("am I free at X?", "what do I have tomorrow?", +"do I have anything on Friday?", "what's my schedule for next week?"): + +${bold}STEP 1 -- DETERMINE THE TIME RANGE:${bold} +- "Am I free at 2pm Tuesday?" -> afterStart = Tuesday 00:00 UTC, beforeEnd = Tuesday 23:59 UTC + (fetch all bookings for that day, then check if any overlap with 2pm) +- "What do I have tomorrow?" -> afterStart = tomorrow 00:00, beforeEnd = tomorrow 23:59 +- "Am I free next week?" -> afterStart = next Monday 00:00, beforeEnd = next Friday 23:59 +- "What's on my calendar March 20?" -> afterStart = Mar 20 00:00, beforeEnd = Mar 20 23:59 +- Always convert to UTC using the user's timezone. + +${bold}STEP 2 -- FETCH BOOKINGS:${bold} +- Call list_bookings with status "upcoming", the computed afterStart/beforeEnd, sortStart "asc", + and take 20 (to capture a full day/week). +- Do NOT use check_busy_times -- it requires calendar-specific credentials and is unreliable. + +${bold}STEP 3 -- ANSWER THE QUESTION:${bold} +- "Am I free at [specific time]?": + - Check if any returned booking overlaps with the requested time. + - If no overlap: "Yes, you're free at [time] on [date]!" + - If overlap: "No, you have [Title] from [start] to [end] at that time." + - Also mention nearby bookings so the user sees the full picture: + "Your closest bookings that day are [Title] at [time] and [Title] at [time]." +- "What do I have tomorrow?" / "What's my schedule for [date]?": + - List all bookings for that day in chronological order. + - If no bookings: "Your [day] is clear -- no meetings scheduled!" + - If bookings exist: show them as a bullet list with title, time range, and attendees. +- "Am I free next week?" / "What does my week look like?": + - List all bookings grouped by day. + - Highlight free days: "Tuesday and Thursday are completely free." + +${bold}EDGE CASES:${bold} +- If the user asks about a past date: answer from past bookings (status "past" instead of "upcoming"). +- If the user says "am I free?" with no date/time: ask "Which date or time would you like me to check?" +- If the user asks "am I free for a 30-min meeting at 2pm?": check if there's a gap of at least + 30 minutes starting at 2pm (no booking overlapping 2:00-2:30). +- "Block off" or "mark as busy" requests: explain that Cal.com bookings are created through + event types -- suggest they create a "Focus Time" or "Blocked" event type, or block time + directly in their connected calendar (Google Calendar, Outlook, etc.). + +## Profile Management + +When the user asks about or wants to change their profile settings: + +${bold}VIEWING PROFILE:${bold} +- "What's my timezone?", "What's my email?" -- answer from the "Your Account" section above. Do NOT call get_my_profile unless the user explicitly says "refresh" or you suspect the cached data is stale. +- "Show my full profile" -- call get_my_profile to get all fields including bio, time format, week start, locale. + +${bold}UPDATING PROFILE:${bold} +- Always confirm before making changes. Show what will change: + "I'll update your timezone from Asia/Kolkata to America/Los_Angeles. Confirm?" +- If the user says "change my timezone to PST", resolve the abbreviation to the IANA timezone: + PST/PDT -> America/Los_Angeles, EST/EDT -> America/New_York, CST/CDT -> America/Chicago, + MST/MDT -> America/Denver, IST -> Asia/Kolkata, GMT/UTC -> UTC, CET/CEST -> Europe/Berlin, + BST -> Europe/London, JST -> Asia/Tokyo, AEST -> Australia/Sydney, NZST -> Pacific/Auckland. + If ambiguous (e.g. "CST" could be US Central or China Standard), ask the user to clarify. +- For email changes: warn the user that email updates require verification -- "I'll request the change + to [new email]. Cal.com will send a verification email to the new address. Your current email stays + active until you verify." +- After a successful update, confirm what changed: "Done! Your timezone is now America/Los_Angeles. + All future time displays will use this timezone." + +${bold}FIELDS THE USER CAN UPDATE:${bold} +- name -- display name +- email -- requires verification (see above) +- timeZone -- IANA timezone string (e.g. "America/New_York") +- timeFormat -- 12-hour or 24-hour clock +- weekStart -- which day the week starts on (Monday, Sunday, etc.) +- locale -- language preference (e.g. "en", "es", "de") +- bio -- short bio text + +${bold}FAST-PATH:${bold} +- If the user says "set my timezone to PST" or "change my name to John" with clear intent, + show the confirmation and proceed on "yes". Do NOT ask for additional fields. +- If the user says "update my profile" without specifying what to change, ask what they'd like to update. ## Timezone Conversion - IST = Asia/Kolkata (UTC+5:30) @@ -112,14 +439,106 @@ If the user's latest message is a greeting, status check, or short casual messag ## Resuming Previous Tasks Do NOT automatically resume an incomplete task from earlier in the conversation. Only continue a prior task if the user's latest message explicitly asks you to (e.g. "yes, go ahead", "ok book it", "continue"). A casual message is NOT a continuation request. +## Managing Schedules / Working Hours + +When the user asks about or wants to change their working hours or availability schedule: + +${bold}VIEWING SCHEDULES:${bold} +- "What are my working hours?" / "Show my schedule" -> call list_schedules. If only 1 schedule, + show its availability directly. If multiple, list them and ask which one to view in detail. +- Display availability in a readable format: + "Your working hours (Work Hours schedule): + Mon-Fri: 9:00 AM - 5:00 PM + Sat-Sun: Not available" +- Group consecutive days with the same hours (e.g. "Mon-Fri" instead of listing each day). +- Show overrides if any: "Exception: Mar 20 — 12:00 PM - 3:00 PM" + +${bold}UPDATING WORKING HOURS:${bold} +- "Change my working hours to 10am-6pm" -> identify which schedule (use default if only one), + confirm the change, then call update_schedule with the new availability. +- IMPORTANT: The availability array REPLACES all existing windows. When updating, include ALL + desired windows, not just the changed ones. For example, if the user says "add Saturday 10-2" + to their Mon-Fri 9-5 schedule, the new availability must include both the Mon-Fri AND Saturday entries. +- Before updating, call get_schedule (or use list_schedules data) to fetch the current availability. + Show the before/after comparison: "I'll update your 'Work Hours' schedule: + Before: Mon-Fri 9:00 AM - 5:00 PM + After: Mon-Fri 10:00 AM - 6:00 PM + Confirm?" +- Time format: always pass HH:MM (24-hour) to the API. Display in the user's preferred format. + +${bold}DATE OVERRIDES:${bold} +- "I'm only available 2-4pm on March 20" -> call update_schedule with an override for that date. +- IMPORTANT: Like availability, the overrides array REPLACES all existing overrides. Fetch current + overrides first and merge the new one in. +- "Remove my override for March 20" -> fetch current overrides, remove the matching date, update. +- "Block off March 21 entirely" -> this can't be done with overrides (overrides define AVAILABLE + times, not blocked times). Suggest the user block the day in their connected calendar instead. + +${bold}CREATING A NEW SCHEDULE:${bold} +- "Create a weekend schedule" -> ask for the hours, then call create_schedule. +- If the user doesn't specify hours, use the default (Mon-Fri 9-5) and let them know. +- After creation, remind them to assign it to an event type if needed: + "Created 'Weekend Hours'. To use it for a specific event type, I can update the event type's + schedule assignment." + +${bold}COMMON REQUESTS:${bold} +- "Make me unavailable on Fridays" -> update availability to remove Friday +- "Add lunch break 12-1pm" -> split the day into two windows (e.g. 9:00-12:00 and 13:00-17:00) +- "Set different hours for Monday" -> update with separate Monday entry and rest-of-week entry + +## Managing Event Types + +When the user wants to create, update, or delete an event type: + +${bold}CREATING AN EVENT TYPE:${bold} +- Required info: title and duration. Everything else has sensible defaults. +- Auto-generate the slug from the title: lowercase, hyphens for spaces, no special characters. + Example: "Product Discussion" -> "product-discussion", "Quick 15-min Chat" -> "quick-15-min-chat". +- If the user says "create a 45-minute meeting type", ask for a title. If they say + "create a meeting called Product Discussion, 45 minutes", you have everything -- confirm and create. +- Show the result after creation: title, duration, slug, and the booking URL: + ${CALCOM_APP_URL}/{username}/{slug} + +${bold}UPDATING AN EVENT TYPE:${bold} +- First, identify which event type to update. If the user says "change my 30-min meeting to 45 min", + call list_event_types to find it. If multiple match, ask the user to pick. +- To inspect an event type's full configuration before updating, call get_event_type with its ID. +- Show what will change before updating: "I'll update '30 Minute Meeting' duration from 30 to 45 minutes. Confirm?" +- After update, show the updated fields. + +${bold}DELETING AN EVENT TYPE:${bold} +- ALWAYS confirm before deleting: "Are you sure you want to delete '[Title]'? This cannot be undone + and any existing booking links using this event type will stop working." +- NEVER delete without explicit confirmation. +- After deletion, confirm: "'[Title]' has been deleted." + +${bold}LISTING EVENT TYPES:${bold} +- When the user asks "what are my event types?" or "show my meeting types", call list_event_types. +- Show each as: title, duration, slug, and whether it's hidden. +- Include the booking URL for each: ${CALCOM_APP_URL}/{username}/{slug} + +${bold}COMMON REQUESTS:${bold} +- "Hide my 30-min meeting" -> update_event_type with hidden: true +- "Unhide" / "make visible" -> update_event_type with hidden: false +- "Rename my meeting to X" -> update_event_type with title and optionally slug +- "Add a 10-minute buffer before meetings" -> update_event_type with beforeEventBuffer: 10 +- "Require at least 2 hours notice" -> update_event_type with minimumBookingNotice: 120 (minutes) +- "Change slot intervals to 15 minutes" -> update_event_type with slotInterval: 15 +- For advanced settings like custom booking fields, recurring events, or seat-based events, + suggest the user visit ${CALCOM_APP_URL}/event-types. + ## CRITICAL RULES FOR TOOL USAGE -1. check_account_linked: Call this ONCE per conversation thread. If the conversation history already contains a successful check_account_linked result, skip it entirely and proceed directly with the user's request. -2. Re-using data from history: Before calling any tool, check whether the answer is already in the conversation history from a previous turn. If list_bookings, list_event_types, check_availability, or any other tool was already called earlier in this thread and returned the data you need, use that data — do NOT call the tool again. -3. Call check_availability EXACTLY ONCE per booking attempt. Pick the slot that best matches what the user asked for. Do NOT call it again to search other dates or times. -4. If the exact requested time is not in the slot list, pick the closest available slot, tell the user, then proceed to book_meeting — do not loop. -5. After getting tool results, respond with a text message. Do NOT call more tools unless the user asks for something new. -6. Never call a tool with empty or placeholder arguments. -7. Never call the same tool twice in a row. +1. BEFORE calling ANY tool, check [CACHED TOOL DATA] at the top of this message. If \`list_event_types\` data is there, you ALREADY HAVE the event types — do NOT call it again. If \`_resolved_attendees\` is there, you ALREADY HAVE attendee info. If \`_booking_intent\` is there, honor the urgency. +2. NEVER call the same tool more than once in a single step. +3. NEVER call check_availability more than once per step. Pick ONE eventTypeId and ONE date range. +4. If check_availability returns \`totalSlots: 0\`, read the \`noSlotsReason\` and present the \`nextAvailableSlots\` as alternatives. NEVER say "I wasn't able to check" or "I couldn't check" — the check succeeded, there are just no slots for that date. +5. If check_availability returns slots, USE them in your response. Do not discard results. +6. NEVER call \`check_availability\` for another user's event type — it requires the host's auth token. Use \`check_availability_public\` instead (pass eventTypeSlug + username). +7. Never call a tool with empty or placeholder arguments. +8. During a booking flow, sequential tool calls across steps are expected (list_event_types → check_availability → book_meeting). After completing the task, respond with text. +9. NEVER call create_event_type, update_event_type, or delete_event_type during a booking flow or unless the user explicitly asked to manage an event type. For delete, ALWAYS confirm first. +10. For "am I free?" questions, use list_bookings with afterStart/beforeEnd date filters -- do NOT use check_busy_times. +11. NEVER call reschedule_booking without first checking availability via check_availability or check_availability_public. Always pass bookingUidToReschedule when checking slots for a reschedule. ## Formatting Rules ${ @@ -135,53 +554,74 @@ ${ ## Displaying Bookings and Lists When listing bookings, event types, availability slots, schedules, busy times, or calendar links: ALWAYS use bullet lists (never tables). Include video/meeting links inline. The link is in the \`location\` field of each booking object. -- Bookings: \`• *Title* – Date/Time – \` (Slack) or \`• **Title** – Date/Time – [Join Meeting](url)\` (Telegram) -- Event types: \`• *Title* – duration\` (Slack) or \`• **Title** – duration\` (Telegram) -- Availability: \`• Date/Time – \` (Slack) or \`• Date/Time – [Book](url)\` (Telegram) -- Calendar links (get_calendar_links): format each service (Google, Outlook, ICS) as a link per platform Never say "you can find the link in the booking details" — show it directly. ## Behavior - ${linkInstruction} - When showing availability, format times in the user's timezone if known. -- For confirm/decline: use on bookings with status "pending" or "unconfirmed". -- For schedules: when asked about working hours or availability windows, use schedule tools. +- For confirm/decline: see the "Confirming or Declining a Booking" section above. +- For schedules and working hours: see the "Managing Schedules / Working Hours" section above. - Keep responses under 200 words. - Never fabricate data. Only use data from tool results. - Bookings returned by list_bookings are already filtered to only your own (where you are a host or attendee). Never imply the user might be seeing others' bookings. + +FINDING PAST MEETINGS WITH SOMEONE: +- When the user asks "when did I last talk to X?" or "find my meetings with X": + 1. If an @mention was resolved with an email, use attendeeEmail to filter. + 2. If only a name is given, use attendeeName to filter. + 3. If the user provides an email directly (e.g. "check david@cal.com"), use attendeeEmail. + 4. Always pass status: "past" and sortStart: "desc" to get the most recent meeting first. + 5. Use take: 10 to get enough history. +- IMPORTANT: The attendeeEmail/attendeeName API filters only match the ATTENDEES list, not the host. + If the person is the HOST of the meeting (e.g. the user booked onto their calendar), the attendee filter will miss it. + When attendee filters return no results, do a second call: list_bookings with status "past", sortStart "desc", take 10 (no attendee filter), + then scan the returned \`hosts\` field for the person's name or email. Each booking now includes a \`hosts\` array alongside \`attendees\`. +- Show results as a list with title, date/time, host, and attendees. + +MARKING NO-SHOWS: +- When the user says "X didn't show up" or "mark my 2pm meeting as no-show": + 1. Identify the booking -- use list_bookings with status "past" if needed. + 2. Ask WHO was absent: "Was it the host, an attendee, or everyone?" + - If the user is the host and says "they didn't show up" -> mark attendees absent. + - If the user is the attendee and says "they didn't show up" -> mark host absent. + - If clear from context (e.g. "the attendee didn't show"), skip asking. + 3. Call mark_no_show with the appropriate flags. + 4. Confirm: "Marked [name] as a no-show for [Title] on [Date]." +- Only works for PAST bookings. If the booking hasn't happened yet, tell the user to cancel instead. + +UNLINKING ACCOUNT: +- When the user says "unlink my account", "disconnect", "remove my cal.com connection": + 1. ALWAYS confirm first: "This will disconnect your Cal.com account from this chat platform. + You'll need to re-authenticate to use any Cal.com features. Are you sure?" + 2. On "yes" -> call unlink_account. Confirm: "Your Cal.com account has been disconnected. + You can reconnect anytime by mentioning me or using /cal." + 3. On "no" -> acknowledge and do nothing. +- NEVER unlink without explicit confirmation. +- If the user asks "how do I reconnect?" after unlinking, tell them to mention the bot + or use /cal -- the OAuth link will be shown automatically. + - Meeting video links (Zoom, Google Meet, Teams, etc.) are in the \`location\` field of booking objects returned by list_bookings or get_booking. Never call get_calendar_links to find a video link — that tool only returns "Add to Calendar" links for calendar apps (Google Calendar, Outlook, ICS).`; } -async function getAccessTokenOrNull(teamId: string, userId: string): Promise { +function makeFormatSlot(tz: string) { + return (time: string) => + new Intl.DateTimeFormat("en-US", { + timeZone: tz, + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + }).format(new Date(time)); +} + +function getAccessTokenOrNull(teamId: string, userId: string): Promise { return getValidAccessToken(teamId, userId); } -function createCalTools(teamId: string, userId: string, lookupPlatformUser?: LookupPlatformUserFn) { +function createCalTools(teamId: string, userId: string, platform: string, lookupPlatformUser?: LookupPlatformUserFn) { return { - check_account_linked: tool({ - description: - "Check if the current user has linked their Cal.com account. Call this ONCE per conversation thread. If the conversation history already shows this was called and returned linked:true, skip this tool entirely and proceed with the user's request.", - inputSchema: z.object({}), - execute: async () => { - const linked = await getLinkedUser(teamId, userId); - if (linked) { - return { - status: "LINKED", - username: linked.calcomUsername, - email: linked.calcomEmail, - timeZone: linked.calcomTimeZone, - instruction: - "Account is linked. Proceed with the user's request using other tools. Do NOT call check_account_linked again.", - }; - } - return { - status: "NOT_LINKED", - instruction: - "Tell the user to connect their Cal.com account by clicking the 'Continue with Cal.com' button or running /cal link. Do NOT call any other tools.", - }; - }, - }), - lookup_platform_user: tool({ description: "Look up a user on the current platform by their user ID to get their name and email. On Slack, resolves mentions like <@USER_ID>. On Telegram, this is not available — ask the user to provide the attendee's name and email directly.", @@ -223,8 +663,8 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo }), unlink_account: tool({ - description: "Unlink the user's Cal.com account.", - inputSchema: z.object({}), + description: "Unlink the user's Cal.com account from this chat platform. This removes the stored OAuth connection. The user will need to re-authenticate to use Cal.com features again. Always confirm before calling.", + inputSchema: z.object({}).passthrough(), execute: async () => { const linked = await getLinkedUser(teamId, userId); if (!linked) { @@ -236,8 +676,8 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo }), get_my_profile: tool({ - description: "Get the linked user's Cal.com profile information.", - inputSchema: z.object({}), + description: "Get the linked user's full Cal.com profile from the API. Only call this when the user asks for their full profile or fields not in the 'Your Account' section (like bio, time format, week start, locale). For basic info (email, username, timezone), use the pre-verified data in the system prompt.", + inputSchema: z.object({}).passthrough(), execute: async () => { const token = await getAccessTokenOrNull(teamId, userId); if (!token) return { error: "Account not connected." }; @@ -253,9 +693,12 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo list_event_types: tool({ description: "List YOUR Cal.com event types (the meeting types you offer as a host). Use this to pick which event type to book when someone wants to meet with you.", - inputSchema: z.object({}), + inputSchema: z.object({}).passthrough(), execute: async () => { - const token = await getAccessTokenOrNull(teamId, userId); + const [token, linked] = await Promise.all([ + getAccessTokenOrNull(teamId, userId), + getLinkedUser(teamId, userId), + ]); if (!token) return { error: "Account not connected." }; try { const types = await getEventTypes(token); @@ -267,6 +710,10 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo duration: et.length, description: et.description, hidden: et.hidden, + bookingFields: et.bookingFields, + bookingUrl: linked?.calcomUsername + ? `${CALCOM_APP_URL}/${linked.calcomUsername}/${et.slug}` + : null, })), }; } catch (err) { @@ -275,9 +722,78 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo }, }), + list_event_types_by_username: tool({ + description: + "Fetch another person's public Cal.com event types by their username. Use when the user wants to book on someone else's calendar instead of their own.", + inputSchema: z.object({ + username: z.string().describe("The Cal.com username (e.g. 'peer', 'dhairyashil')"), + }), + execute: async ({ username }) => { + try { + const types = await getEventTypesByUsername(username); + if (types.length === 0) { + return { + username, + error: `No public event types found for username "${username}". The user may not exist or has no public event types.`, + }; + } + return { + username, + eventTypes: types.map((et) => ({ + id: et.id, + title: et.title, + slug: et.slug, + duration: et.length, + description: et.description, + hidden: et.hidden, + bookingUrl: et.bookingUrl, + bookingFields: et.bookingFields, + })), + }; + } catch (err) { + return { + username, + error: err instanceof Error ? err.message : "Failed to fetch event types for this user", + }; + } + }, + }), + + get_event_type: tool({ + description: + "Get full details of a single event type by ID. Returns bookingFields (custom form fields), duration, description, visibility, and booking URL. Use when you already have the event type ID and need its details (e.g., to check required custom fields before booking) without re-listing all event types.", + inputSchema: z.object({ + eventTypeId: z.number().describe("The event type ID"), + }), + execute: async ({ eventTypeId }) => { + const [token, linked] = await Promise.all([ + getAccessTokenOrNull(teamId, userId), + getLinkedUser(teamId, userId), + ]); + if (!token) return { error: "Account not connected." }; + try { + const et = await getEventType(token, eventTypeId); + return { + id: et.id, + title: et.title, + slug: et.slug, + duration: et.length, + description: et.description, + hidden: et.hidden, + bookingFields: et.bookingFields, + bookingUrl: linked?.calcomUsername + ? `${CALCOM_APP_URL}/${linked.calcomUsername}/${et.slug}` + : null, + }; + } catch (err) { + return { error: err instanceof Error ? err.message : "Failed to fetch event type" }; + } + }, + }), + check_availability: tool({ description: - "Check YOUR available time slots for a specific event type. You are always the host. Call this ONCE — use the returned slots to find the best match and proceed to book_meeting immediately. Do NOT call this again.", + "Check YOUR available time slots for a specific event type. Only works for your own event types (requires your auth token). Do NOT use this for another user's event types — there is no public availability API.", inputSchema: z.object({ eventTypeId: z.number().describe("The event type ID to check availability for"), daysAhead: z @@ -293,45 +809,72 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo .describe( "ISO 8601 date to start from (defaults to now). Use this when the user specifies a date." ), + bookingUidToReschedule: z + .string() + .nullable() + .optional() + .describe("When rescheduling, pass the original booking UID so its time slot is not blocked."), }), - execute: async ({ eventTypeId, daysAhead, startDate }) => { + execute: async ({ eventTypeId, daysAhead, startDate, bookingUidToReschedule }) => { const token = await getAccessTokenOrNull(teamId, userId); if (!token) return { error: "Account not connected." }; const linked = await getLinkedUser(teamId, userId); const tz = linked?.calcomTimeZone ?? "UTC"; + const formatSlot = makeFormatSlot(tz); + try { const from = startDate ? new Date(startDate) : new Date(); - const end = new Date(from.getTime() + (daysAhead ?? 7) * 24 * 60 * 60 * 1000); + const end = new Date(from.getTime() + (daysAhead ?? 7) * MS_PER_DAY); const slotsMap = await getAvailableSlots(token, { eventTypeId, start: from.toISOString(), end: end.toISOString(), timeZone: tz, + ...(bookingUidToReschedule ? { bookingUidToReschedule } : {}), }); const allSlots = Object.entries(slotsMap).flatMap(([date, slots]) => slots .filter((s) => s.available) - .map((s) => ({ - date, - time: s.time, - formatted: new Intl.DateTimeFormat("en-US", { - timeZone: tz, - weekday: "short", - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - hour12: true, - }).format(new Date(s.time)), - })) + .map((s) => ({ date, time: s.time, formatted: formatSlot(s.time) })) ); + if (allSlots.length === 0) { + const dayName = from.toLocaleDateString("en-US", { weekday: "long", timeZone: tz }); + const isWeekend = ["Saturday", "Sunday"].includes(dayName); + const noSlotsReason = isWeekend + ? `No availability on ${dayName}. The schedule does not include weekends.` + : `No available slots in the requested date range (${formatSlot(from.toISOString())} – ${formatSlot(end.toISOString())}).`; + + const extEnd = new Date(from.getTime() + EXTENDED_SEARCH_DAYS * MS_PER_DAY); + const extSlotsMap = await getAvailableSlots(token, { + eventTypeId, + start: from.toISOString(), + end: extEnd.toISOString(), + timeZone: tz, + ...(bookingUidToReschedule ? { bookingUidToReschedule } : {}), + }); + const nextSlots = Object.entries(extSlotsMap) + .flatMap(([date, slots]) => + slots.filter((s) => s.available).map((s) => ({ date, time: s.time, formatted: formatSlot(s.time) })) + ) + .slice(0, MAX_NEXT_AVAILABLE_SLOTS); + + return { + timeZone: tz, + totalSlots: 0, + slots: [], + noSlotsReason, + nextAvailableSlots: nextSlots, + instruction: "Tell the user why the requested date has no availability and present the nextAvailableSlots as alternatives. Do NOT say you 'couldn't check' — the check succeeded, there are just no slots.", + }; + } + return { timeZone: tz, totalSlots: allSlots.length, - slots: allSlots.slice(0, 15), - hasMore: allSlots.length > 15, + slots: allSlots.slice(0, MAX_SLOTS_RETURNED), + hasMore: allSlots.length > MAX_SLOTS_RETURNED, }; } catch (err) { return { error: err instanceof Error ? err.message : "Failed to fetch availability" }; @@ -339,9 +882,114 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo }, }), + check_availability_public: tool({ + description: + "Check available time slots on ANOTHER user's public Cal.com calendar. Use this after list_event_types_by_username — pass the event type slug and username. Does NOT require the other user's auth token.", + inputSchema: z.object({ + eventTypeSlug: z + .string() + .describe("The event type slug (e.g. 'meet', '30min') from list_event_types_by_username result"), + username: z + .string() + .describe("The Cal.com username of the host (e.g. 'peer')"), + daysAhead: z + .number() + .nullable() + .optional() + .default(7) + .describe("Number of days ahead to check. Default 7."), + startDate: z + .string() + .nullable() + .optional() + .describe( + "ISO 8601 date to start from (defaults to now). Use this when the user specifies a date." + ), + duration: z + .number() + .nullable() + .optional() + .describe("Duration in minutes. Only needed if the event type supports multiple durations."), + bookingUidToReschedule: z + .string() + .nullable() + .optional() + .describe("When rescheduling, pass the original booking UID so its time slot is not blocked."), + }), + execute: async ({ eventTypeSlug, username, daysAhead, startDate, duration, bookingUidToReschedule }) => { + const linked = await getLinkedUser(teamId, userId); + const tz = linked?.calcomTimeZone ?? "UTC"; + const formatSlot = makeFormatSlot(tz); + + try { + const from = startDate ? new Date(startDate) : new Date(); + const end = new Date(from.getTime() + (daysAhead ?? 7) * MS_PER_DAY); + const slotsMap = await getAvailableSlotsPublic({ + eventTypeSlug, + username, + start: from.toISOString().split("T")[0] ?? "", + end: end.toISOString().split("T")[0] ?? "", + timeZone: tz, + ...(duration ? { duration } : {}), + ...(bookingUidToReschedule ? { bookingUidToReschedule } : {}), + }); + + const allSlots = Object.entries(slotsMap).flatMap(([date, slots]) => + slots + .filter((s) => s.available) + .map((s) => ({ date, time: s.time, formatted: formatSlot(s.time) })) + ); + + if (allSlots.length === 0) { + const dayName = from.toLocaleDateString("en-US", { weekday: "long", timeZone: tz }); + const isWeekend = ["Saturday", "Sunday"].includes(dayName); + const noSlotsReason = isWeekend + ? `No availability on ${dayName}. ${username}'s schedule does not include weekends.` + : `No available slots for ${username} in the requested date range (${formatSlot(from.toISOString())} – ${formatSlot(end.toISOString())}).`; + + const extEnd = new Date(from.getTime() + EXTENDED_SEARCH_DAYS * MS_PER_DAY); + const extSlotsMap = await getAvailableSlotsPublic({ + eventTypeSlug, + username, + start: from.toISOString().split("T")[0] ?? "", + end: extEnd.toISOString().split("T")[0] ?? "", + timeZone: tz, + ...(duration ? { duration } : {}), + ...(bookingUidToReschedule ? { bookingUidToReschedule } : {}), + }); + const nextSlots = Object.entries(extSlotsMap) + .flatMap(([date, slots]) => + slots.filter((s) => s.available).map((s) => ({ date, time: s.time, formatted: formatSlot(s.time) })) + ) + .slice(0, MAX_NEXT_AVAILABLE_SLOTS); + + return { + timeZone: tz, + username, + totalSlots: 0, + slots: [], + noSlotsReason, + nextAvailableSlots: nextSlots, + instruction: `Tell the user why ${username} has no availability for the requested date and present the nextAvailableSlots as alternatives.`, + }; + } + + return { + timeZone: tz, + username, + totalSlots: allSlots.length, + slots: allSlots.slice(0, MAX_SLOTS_RETURNED), + hasMore: allSlots.length > MAX_SLOTS_RETURNED, + }; + } catch (err) { + return { error: err instanceof Error ? err.message : `Failed to fetch availability for ${username}` }; + } + }, + }), + book_meeting: tool({ description: - "Book a meeting on YOUR Cal.com calendar. You are always the host — use your own event type ID and availability. The attendee is the person you're meeting with; provide their name and email (get these from lookup_slack_user if they were @mentioned).", + "Book a meeting on YOUR Cal.com calendar. You are always the host — use your own event type ID and availability. The primary attendee is the person you're meeting with; provide their name and email (get these from lookup_platform_user if they were @mentioned). Use guestEmails for additional email-only attendees.", inputSchema: z.object({ eventTypeId: z.number().describe("Your event type ID to book"), startTime: z @@ -356,7 +1004,20 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo .describe( "Attendee's timezone (e.g. 'Asia/Kolkata'). Defaults to your timezone if omitted." ), - notes: z.string().nullable().optional().describe("Optional notes for the booking"), + guestEmails: z + .array(z.string()) + .nullable() + .optional() + .describe( + "Email addresses of additional attendees (email-only). Use when you have emails but not full details for extra guests." + ), + bookingFieldsResponses: z + .record(z.string(), z.string()) + .nullable() + .optional() + .describe( + "Custom booking field responses. Keys are field slugs from the event type's bookingFields, values are the user's answers. The default 'Notes' field has slug 'notes'. Required when the event type has required custom fields." + ), }), execute: async ({ eventTypeId, @@ -364,13 +1025,22 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo attendeeName, attendeeEmail, attendeeTimeZone, - notes, + guestEmails, + bookingFieldsResponses, }) => { const token = await getAccessTokenOrNull(teamId, userId); if (!token) return { error: "Account not connected." }; const linked = await getLinkedUser(teamId, userId); try { + const metadata: Record = {}; + if (platform === "slack") { + metadata.slack_team_id = teamId; + metadata.slack_user_id = userId; + } else if (platform === "telegram") { + metadata.telegram_chat_id = userId; + } + const booking = await createBooking(token, { eventTypeId, start: startTime, @@ -379,7 +1049,9 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo email: attendeeEmail, timeZone: attendeeTimeZone ?? linked?.calcomTimeZone ?? "UTC", }, - notes: notes ?? undefined, + guests: guestEmails?.filter(Boolean) ?? undefined, + bookingFieldsResponses: bookingFieldsResponses ?? undefined, + metadata, }); return { @@ -398,9 +1070,140 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo }, }), + book_meeting_public: tool({ + description: + "Book a meeting on ANOTHER user's Cal.com calendar using their public event type. Use this for Option B (they host). Does NOT require the host's auth token. Pass eventTypeSlug + username instead of eventTypeId. The attendee is the requesting user (you) — use YOUR name and email.", + inputSchema: z.object({ + eventTypeSlug: z + .string() + .describe("The event type slug from list_event_types_by_username (e.g. 'meet')"), + username: z + .string() + .describe("The Cal.com username of the host (e.g. 'peer')"), + startTime: z + .string() + .describe("Start time in ISO 8601 UTC format (e.g. '2026-03-23T10:30:00Z')"), + attendeeName: z + .string() + .describe("YOUR full name (the requesting user, not the host)"), + attendeeEmail: z + .string() + .describe("YOUR email address (the requesting user, not the host)"), + attendeeTimeZone: z + .string() + .nullable() + .optional() + .describe("Your timezone (e.g. 'Asia/Kolkata'). Defaults to your linked timezone."), + guests: z + .array(z.string()) + .nullable() + .optional() + .describe("Optional additional guest emails"), + lengthInMinutes: z + .number() + .nullable() + .optional() + .describe("Duration in minutes. Only needed if the event type supports multiple durations."), + bookingFieldsResponses: z + .record(z.string(), z.string()) + .nullable() + .optional() + .describe( + "Custom booking field responses. Keys are field slugs from the event type's bookingFields, values are the user's answers. The default 'Notes' field has slug 'notes'. Required when the event type has required custom fields." + ), + }), + execute: async ({ + eventTypeSlug, + username, + startTime, + attendeeName, + attendeeEmail, + attendeeTimeZone, + guests, + lengthInMinutes, + bookingFieldsResponses, + }) => { + const linked = await getLinkedUser(teamId, userId); + + try { + const metadata: Record = {}; + if (platform === "slack") { + metadata.slack_team_id = teamId; + metadata.slack_user_id = userId; + } else if (platform === "telegram") { + metadata.telegram_chat_id = userId; + } + + const booking = await createBookingPublic({ + eventTypeSlug, + username, + start: startTime, + attendee: { + name: attendeeName, + email: attendeeEmail, + timeZone: attendeeTimeZone ?? linked?.calcomTimeZone ?? "UTC", + }, + guests: guests?.filter(Boolean) ?? undefined, + lengthInMinutes: lengthInMinutes ?? undefined, + bookingFieldsResponses: bookingFieldsResponses ?? undefined, + metadata, + }); + + return { + success: true, + bookingUid: booking.uid, + title: booking.title, + start: booking.start, + end: booking.end, + meetingUrl: booking.meetingUrl, + attendees: booking.attendees.map((a) => ({ name: a.name, email: a.email })), + }; + } catch (err) { + return { error: err instanceof Error ? err.message : "Failed to create booking" }; + } + }, + }), + + add_booking_attendee: tool({ + description: + "Add a full attendee record (name + timezone) to an existing booking. Use after book_meeting for additional attendees resolved via lookup_platform_user on Slack where you have full profile details.", + inputSchema: z.object({ + bookingUid: z.string().describe("The booking UID returned by book_meeting"), + attendeeName: z.string().describe("Full name of the additional attendee"), + attendeeEmail: z.string().describe("Email address of the additional attendee"), + attendeeTimeZone: z + .string() + .nullable() + .optional() + .describe( + "Attendee's timezone (e.g. 'America/New_York'). Defaults to host timezone if omitted." + ), + }), + execute: async ({ bookingUid, attendeeName, attendeeEmail, attendeeTimeZone }) => { + const token = await getAccessTokenOrNull(teamId, userId); + if (!token) return { error: "Account not connected." }; + const linked = await getLinkedUser(teamId, userId); + + try { + await addBookingAttendee(token, bookingUid, { + name: attendeeName, + email: attendeeEmail, + timeZone: attendeeTimeZone ?? linked?.calcomTimeZone ?? "UTC", + }); + return { + success: true, + bookingUid, + addedAttendee: { name: attendeeName, email: attendeeEmail }, + }; + } catch (err) { + return { error: err instanceof Error ? err.message : "Failed to add attendee to booking" }; + } + }, + }), + list_bookings: tool({ description: - "List the user's bookings. Can filter by status: upcoming, past, cancelled, recurring, unconfirmed.", + "List the user's bookings with pagination. Can filter by status, attendee name/email, and date range. Supports sorting by start time. Returns hosts and attendees for each booking. Use skip/take for pagination. Note: attendeeEmail/attendeeName filters only match attendees, not hosts.", inputSchema: z.object({ status: z .enum(["upcoming", "past", "cancelled", "recurring", "unconfirmed"]) @@ -408,14 +1211,45 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo .optional() .default("upcoming") .describe("Booking status filter. Default: upcoming."), + attendeeEmail: z + .string() + .nullable() + .optional() + .describe("Filter by attendee email address."), + attendeeName: z + .string() + .nullable() + .optional() + .describe("Filter by attendee name (partial match)."), + afterStart: z + .string() + .nullable() + .optional() + .describe("Only bookings starting after this ISO 8601 date."), + beforeEnd: z + .string() + .nullable() + .optional() + .describe("Only bookings ending before this ISO 8601 date."), + sortStart: z + .enum(["asc", "desc"]) + .nullable() + .optional() + .describe("Sort by start time. Use 'desc' for most recent first."), take: z .number() .nullable() .optional() .default(5) .describe("Max bookings to return. Default: 5."), + skip: z + .number() + .nullable() + .optional() + .default(0) + .describe("Number of bookings to skip for pagination. Default: 0."), }), - execute: async ({ status, take }) => { + execute: async ({ status, attendeeEmail, attendeeName, afterStart, beforeEnd, sortStart, take, skip }) => { const [token, linked] = await Promise.all([ getAccessTokenOrNull(teamId, userId), getLinkedUser(teamId, userId), @@ -425,22 +1259,41 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo const currentUser = linked ? { id: linked.calcomUserId, email: linked.calcomEmail } : undefined; + const requestedTake = take ?? 5; const bookings = await getBookings( token, - { status: status ?? "upcoming", take: take ?? 5 }, + { + status: status ?? "upcoming", + take: requestedTake + 1, + skip: skip ?? 0, + ...(attendeeEmail ? { attendeeEmail } : {}), + ...(attendeeName ? { attendeeName } : {}), + ...(afterStart ? { afterStart } : {}), + ...(beforeEnd ? { beforeEnd } : {}), + ...(sortStart ? { sortStart } : {}), + }, currentUser ); + const hasMore = bookings.length > requestedTake; + const trimmed = hasMore ? bookings.slice(0, requestedTake) : bookings; return { - bookings: bookings.map((b) => ({ + bookings: trimmed.map((b) => ({ uid: b.uid, title: b.title, status: b.status, start: b.start, end: b.end, + hosts: b.hosts?.map((h) => ({ name: h.name, email: h.email })) ?? [], attendees: b.attendees.map((a) => ({ name: a.name, email: a.email })), meetingUrl: b.meetingUrl, location: b.location, + eventType: b.eventType + ? { id: b.eventType.id, title: b.eventType.title, slug: b.eventType.slug } + : null, + description: b.description, + recurringBookingUid: b.recurringBookingUid ?? null, })), + hasMore, manageUrl: `${CALCOM_APP_URL}/bookings`, }; } catch (err) { @@ -450,7 +1303,7 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo }), get_booking: tool({ - description: "Get details of a specific booking by its UID.", + description: "Get full details of a booking by UID, including status, start/end times, hosts, attendees, event type, meeting URL, and location. Use before cancel/reschedule to show the user what they're changing.", inputSchema: z.object({ bookingUid: z.string().describe("The booking UID"), }), @@ -465,9 +1318,15 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo status: b.status, start: b.start, end: b.end, + hosts: b.hosts?.map((h) => ({ name: h.name, email: h.email })) ?? [], attendees: b.attendees.map((a) => ({ name: a.name, email: a.email })), meetingUrl: b.meetingUrl, location: b.location, + eventType: b.eventType + ? { id: b.eventType.id, title: b.eventType.title, slug: b.eventType.slug } + : null, + description: b.description, + recurringBookingUid: b.recurringBookingUid ?? null, }; } catch (err) { return { error: err instanceof Error ? err.message : "Failed to fetch booking" }; @@ -476,17 +1335,31 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo }), cancel_booking: tool({ - description: "Cancel a booking by its UID. Optionally provide a reason.", + description: "Cancel a booking by its UID. Optionally provide a reason. For recurring bookings, set cancelSubsequentBookings to true to cancel this and all future occurrences.", inputSchema: z.object({ bookingUid: z.string().describe("The booking UID to cancel"), reason: z.string().nullable().optional().describe("Cancellation reason"), + cancelSubsequentBookings: z + .boolean() + .nullable() + .optional() + .describe("For recurring bookings only. If true, cancels this booking AND all future occurrences."), }), - execute: async ({ bookingUid, reason }) => { + execute: async ({ bookingUid, reason, cancelSubsequentBookings }) => { const token = await getAccessTokenOrNull(teamId, userId); if (!token) return { error: "Account not connected." }; try { - await cancelBooking(token, bookingUid, reason ?? undefined); - return { success: true, bookingUid }; + const booking = await getBooking(token, bookingUid); + await cancelBooking(token, bookingUid, reason ?? undefined, cancelSubsequentBookings ?? undefined); + return { + success: true, + bookingUid, + title: booking.title, + start: booking.start, + end: booking.end, + attendees: booking.attendees.map((a) => ({ name: a.name, email: a.email })), + cancelledSubsequent: cancelSubsequentBookings ?? false, + }; } catch (err) { return { error: err instanceof Error ? err.message : "Failed to cancel booking" }; } @@ -494,28 +1367,38 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo }), reschedule_booking: tool({ - description: "Reschedule a booking to a new time.", + description: "Reschedule a booking to a new time. Returns both old and new times for confirmation. Pass rescheduledBy with the host's email for auto-confirmation on confirmation-required event types.", inputSchema: z.object({ bookingUid: z.string().describe("The booking UID to reschedule"), newStartTime: z.string().describe("New start time in ISO 8601 format"), reason: z.string().nullable().optional().describe("Reason for rescheduling"), + rescheduledBy: z + .string() + .nullable() + .optional() + .describe("Email of the person rescheduling. Pass the event-type owner's email for auto-confirmation."), }), - execute: async ({ bookingUid, newStartTime, reason }) => { + execute: async ({ bookingUid, newStartTime, reason, rescheduledBy }) => { const token = await getAccessTokenOrNull(teamId, userId); if (!token) return { error: "Account not connected." }; try { - const booking = await rescheduleBooking( + const original = await getBooking(token, bookingUid); + const rescheduled = await rescheduleBooking( token, bookingUid, newStartTime, - reason ?? undefined + reason ?? undefined, + rescheduledBy ?? undefined ); return { success: true, - bookingUid: booking.uid, - title: booking.title, - newStart: booking.start, - newEnd: booking.end, + bookingUid: rescheduled.uid, + title: rescheduled.title, + previousStart: original.start, + previousEnd: original.end, + newStart: rescheduled.start, + newEnd: rescheduled.end, + attendees: rescheduled.attendees.map((a) => ({ name: a.name, email: a.email })), }; } catch (err) { return { error: err instanceof Error ? err.message : "Failed to reschedule booking" }; @@ -524,7 +1407,7 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo }), confirm_booking: tool({ - description: "Confirm a pending booking that requires confirmation.", + description: "Confirm a pending booking. Only works on bookings with status 'pending' (from event types that require manual confirmation). The attendee will be notified once confirmed.", inputSchema: z.object({ bookingUid: z.string().describe("The booking UID to confirm"), }), @@ -532,12 +1415,16 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo const token = await getAccessTokenOrNull(teamId, userId); if (!token) return { error: "Account not connected." }; try { + const details = await getBooking(token, bookingUid); const booking = await confirmBooking(token, bookingUid); return { success: true, bookingUid: booking.uid, title: booking.title, status: booking.status, + start: details.start, + end: details.end, + attendees: details.attendees.map((a) => ({ name: a.name, email: a.email })), }; } catch (err) { return { error: err instanceof Error ? err.message : "Failed to confirm booking" }; @@ -546,7 +1433,7 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo }), decline_booking: tool({ - description: "Decline a pending booking that requires confirmation.", + description: "Decline a pending booking with an optional reason. Only works on bookings with status 'pending'. The attendee will be notified of the decline and reason.", inputSchema: z.object({ bookingUid: z.string().describe("The booking UID to decline"), reason: z.string().nullable().optional().describe("Reason for declining"), @@ -555,12 +1442,17 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo const token = await getAccessTokenOrNull(teamId, userId); if (!token) return { error: "Account not connected." }; try { + const details = await getBooking(token, bookingUid); const booking = await declineBooking(token, bookingUid, reason ?? undefined); return { success: true, bookingUid: booking.uid, title: booking.title, status: booking.status, + start: details.start, + end: details.end, + attendees: details.attendees.map((a) => ({ name: a.name, email: a.email })), + reason: reason ?? null, }; } catch (err) { return { error: err instanceof Error ? err.message : "Failed to decline booking" }; @@ -587,15 +1479,27 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo }), mark_no_show: tool({ - description: "Mark a booking as a no-show (host absent).", + description: + "Mark a booking participant as a no-show. Can mark the host as absent, specific attendees as absent, or both. Use after a past booking where someone didn't show up.", inputSchema: z.object({ - bookingUid: z.string().describe("The booking UID to mark as no-show"), + bookingUid: z.string().describe("The booking UID"), + host: z + .boolean() + .nullable() + .optional() + .describe("Set to true if the host was absent"), + attendeeEmails: z + .array(z.string()) + .nullable() + .optional() + .describe("Email addresses of attendees who were absent"), }), - execute: async ({ bookingUid }) => { + execute: async ({ bookingUid, host, attendeeEmails }) => { const token = await getAccessTokenOrNull(teamId, userId); if (!token) return { error: "Account not connected." }; try { - await markNoShow(token, bookingUid); + const attendees = attendeeEmails?.map((email) => ({ email, absent: true })); + await markNoShow(token, bookingUid, host ?? undefined, attendees); return { success: true, bookingUid }; } catch (err) { return { error: err instanceof Error ? err.message : "Failed to mark no-show" }; @@ -605,7 +1509,7 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo update_profile: tool({ description: - "Update the user's Cal.com profile (name, email, timezone, time format, week start, etc.).", + "Update the user's Cal.com profile. Always confirm with the user before calling. For timezone, use IANA timezone strings (e.g. 'America/New_York', not 'EST'). Email changes require verification.", inputSchema: z.object({ name: z.string().nullable().optional().describe("Display name"), email: z.string().nullable().optional().describe("Email address"), @@ -630,6 +1534,26 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo if (Object.keys(patch).length === 0) return { error: "No fields provided to update." }; try { const me = await updateMe(token, patch); + + // Sync changed fields back to Redis so the cached LinkedUser stays fresh. + // This ensures the system prompt's "Your Account" section and all timezone + // conversions use the updated values. + const linked = await getLinkedUser(teamId, userId); + if (linked) { + let dirty = false; + if (me.email && me.email !== linked.calcomEmail) { + linked.calcomEmail = me.email; + dirty = true; + } + if (me.timeZone && me.timeZone !== linked.calcomTimeZone) { + linked.calcomTimeZone = me.timeZone; + dirty = true; + } + if (dirty) { + await linkUser(teamId, userId, linked); + } + } + return { success: true, name: me.name, email: me.email, timeZone: me.timeZone }; } catch (err) { return { error: err instanceof Error ? err.message : "Failed to update profile" }; @@ -638,7 +1562,7 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo }), check_busy_times: tool({ - description: "Check the user's busy times from all connected calendars for a given period.", + description: "Check the user's busy times from connected calendars. Requires calendar credential info -- prefer using list_bookings with afterStart/beforeEnd filters for availability checks.", inputSchema: z.object({ start: z.string().describe("Start of the range in ISO 8601 format"), end: z.string().describe("End of the range in ISO 8601 format"), @@ -656,8 +1580,8 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo }), list_schedules: tool({ - description: "List all availability schedules for the user.", - inputSchema: z.object({}), + description: "List all availability schedules with their working hours, timezones, and date overrides. Schedules define when the user is bookable (e.g. Mon-Fri 9-5). Each event type can be assigned a specific schedule.", + inputSchema: z.object({}).passthrough(), execute: async () => { const token = await getAccessTokenOrNull(teamId, userId); if (!token) return { error: "Account not connected." }; @@ -669,6 +1593,8 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo name: s.name, timeZone: s.timeZone, isDefault: s.isDefault, + availability: s.availability, + overrides: s.overrides, })), }; } catch (err) { @@ -679,7 +1605,7 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo get_schedule: tool({ description: - "Get details of a specific availability schedule. Use scheduleId 'default' to get the default schedule.", + "Get a schedule's full details including working hours (availability windows) and date-specific overrides. Use scheduleId 'default' for the default schedule.", inputSchema: z.object({ scheduleId: z .union([z.number(), z.literal("default")]) @@ -708,22 +1634,54 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo }), create_schedule: tool({ - description: "Create a new availability schedule.", + description: "Create a new availability schedule with working hours. If availability is not provided, defaults to Monday-Friday 09:00-17:00. After creation, assign it to an event type via update_event_type if needed.", inputSchema: z.object({ name: z.string().describe("Schedule name (e.g. 'Work Hours')"), - timeZone: z.string().describe("Timezone (e.g. 'America/New_York')"), + timeZone: z.string().describe("IANA timezone (e.g. 'America/New_York')"), isDefault: z.boolean().describe("Whether this should be the default schedule"), + availability: z + .array( + z.object({ + days: z + .array(z.enum(["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"])) + .describe("Days this window applies to"), + startTime: z.string().describe("Start time in HH:MM format (e.g. '09:00')"), + endTime: z.string().describe("End time in HH:MM format (e.g. '17:00')"), + }) + ) + .nullable() + .optional() + .describe("Availability windows. Each entry defines days + time range. Defaults to Mon-Fri 09:00-17:00 if omitted."), + overrides: z + .array( + z.object({ + date: z.string().describe("Date in YYYY-MM-DD format"), + startTime: z.string().describe("Start time in HH:MM format"), + endTime: z.string().describe("End time in HH:MM format"), + }) + ) + .nullable() + .optional() + .describe("Date-specific overrides. Use to set different hours for a specific date."), }), - execute: async ({ name, timeZone, isDefault }) => { + execute: async ({ name, timeZone, isDefault, availability, overrides }) => { const token = await getAccessTokenOrNull(teamId, userId); if (!token) return { error: "Account not connected." }; try { - const schedule = await createSchedule(token, { name, timeZone, isDefault }); + const schedule = await createSchedule(token, { + name, + timeZone, + isDefault, + ...(availability ? { availability } : {}), + ...(overrides ? { overrides } : {}), + }); return { success: true, id: schedule.id, name: schedule.name, timeZone: schedule.timeZone, + availability: schedule.availability, + overrides: schedule.overrides, }; } catch (err) { return { error: err instanceof Error ? err.message : "Failed to create schedule" }; @@ -732,18 +1690,42 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo }), update_schedule: tool({ - description: "Update an existing availability schedule.", + description: "Update a schedule's name, timezone, working hours, or date overrides. The availability array REPLACES all existing windows -- always include the complete set of desired hours, not just changes.", inputSchema: z.object({ scheduleId: z.number().describe("The schedule ID to update"), name: z.string().nullable().optional().describe("New schedule name"), - timeZone: z.string().nullable().optional().describe("New timezone"), + timeZone: z.string().nullable().optional().describe("New IANA timezone"), isDefault: z.boolean().nullable().optional().describe("Set as default schedule"), + availability: z + .array( + z.object({ + days: z + .array(z.enum(["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"])) + .describe("Days this window applies to"), + startTime: z.string().describe("Start time in HH:MM format (e.g. '09:00')"), + endTime: z.string().describe("End time in HH:MM format (e.g. '17:00')"), + }) + ) + .nullable() + .optional() + .describe("Availability windows. REPLACES all existing windows. Include ALL desired windows."), + overrides: z + .array( + z.object({ + date: z.string().describe("Date in YYYY-MM-DD format"), + startTime: z.string().describe("Start time in HH:MM format"), + endTime: z.string().describe("End time in HH:MM format"), + }) + ) + .nullable() + .optional() + .describe("Date-specific overrides. REPLACES all existing overrides. Include ALL desired overrides."), }), - execute: async ({ scheduleId, name, timeZone, isDefault }) => { + execute: async ({ scheduleId, name, timeZone, isDefault, availability, overrides }) => { const token = await getAccessTokenOrNull(teamId, userId); if (!token) return { error: "Account not connected." }; const patch = Object.fromEntries( - Object.entries({ name, timeZone, isDefault }).filter(([, v]) => v != null) + Object.entries({ name, timeZone, isDefault, availability, overrides }).filter(([, v]) => v != null) ); if (Object.keys(patch).length === 0) return { error: "No fields provided to update." }; try { @@ -754,6 +1736,8 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo name: schedule.name, timeZone: schedule.timeZone, isDefault: schedule.isDefault, + availability: schedule.availability, + overrides: schedule.overrides, }; } catch (err) { return { error: err instanceof Error ? err.message : "Failed to update schedule" }; @@ -762,7 +1746,7 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo }), delete_schedule: tool({ - description: "Delete an availability schedule by ID.", + description: "Delete an availability schedule. This is irreversible. Event types using this schedule will fall back to the user's default schedule. Always confirm with the user before deleting.", inputSchema: z.object({ scheduleId: z.number().describe("The schedule ID to delete"), }), @@ -779,15 +1763,40 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo }), create_event_type: tool({ - description: "Create a new event type (meeting type) on Cal.com.", + description: "Create a new event type (meeting type) on Cal.com. Requires title, slug, and duration. Optionally set buffers, minimum notice, and slot intervals.", inputSchema: z.object({ title: z.string().describe("Event type title (e.g. '30 Minute Meeting')"), slug: z.string().describe("URL slug (e.g. '30min')"), lengthInMinutes: z.number().describe("Duration in minutes"), description: z.string().nullable().optional().describe("Optional description"), hidden: z.boolean().nullable().optional().describe("Whether to hide from booking page"), + minimumBookingNotice: z + .number() + .nullable() + .optional() + .describe("Minimum minutes of notice required before booking"), + beforeEventBuffer: z + .number() + .nullable() + .optional() + .describe("Buffer minutes blocked before each meeting"), + afterEventBuffer: z + .number() + .nullable() + .optional() + .describe("Buffer minutes blocked after each meeting"), + slotInterval: z + .number() + .nullable() + .optional() + .describe("Slot interval in minutes. Defaults to event duration."), + scheduleId: z + .number() + .nullable() + .optional() + .describe("Availability schedule ID to use for this event type"), }), - execute: async ({ title, slug, lengthInMinutes, description, hidden }) => { + execute: async ({ title, slug, lengthInMinutes, description, hidden, minimumBookingNotice, beforeEventBuffer, afterEventBuffer, slotInterval, scheduleId }) => { const token = await getAccessTokenOrNull(teamId, userId); if (!token) return { error: "Account not connected." }; try { @@ -795,8 +1804,13 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo title, slug, lengthInMinutes, - description: description ?? undefined, - hidden: hidden ?? undefined, + ...(description != null ? { description } : {}), + ...(hidden != null ? { hidden } : {}), + ...(minimumBookingNotice != null ? { minimumBookingNotice } : {}), + ...(beforeEventBuffer != null ? { beforeEventBuffer } : {}), + ...(afterEventBuffer != null ? { afterEventBuffer } : {}), + ...(slotInterval != null ? { slotInterval } : {}), + ...(scheduleId != null ? { scheduleId } : {}), }); return { success: true, id: et.id, title: et.title, slug: et.slug, length: et.length }; } catch (err) { @@ -806,7 +1820,7 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo }), update_event_type: tool({ - description: "Update an existing event type.", + description: "Update an existing event type. Can change title, slug, duration, description, visibility, buffers, minimum booking notice, slot intervals, and schedule assignment.", inputSchema: z.object({ eventTypeId: z.number().describe("The event type ID to update"), title: z.string().nullable().optional().describe("New title"), @@ -814,14 +1828,37 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo lengthInMinutes: z.number().nullable().optional().describe("New duration in minutes"), description: z.string().nullable().optional().describe("New description"), hidden: z.boolean().nullable().optional().describe("Whether to hide from booking page"), + minimumBookingNotice: z + .number() + .nullable() + .optional() + .describe("Minimum minutes of notice required before booking (e.g. 120 for 2 hours)"), + beforeEventBuffer: z + .number() + .nullable() + .optional() + .describe("Buffer minutes blocked before each meeting starts"), + afterEventBuffer: z + .number() + .nullable() + .optional() + .describe("Buffer minutes blocked after each meeting ends"), + slotInterval: z + .number() + .nullable() + .optional() + .describe("Slot interval in minutes (e.g. 15 means slots at 9:00, 9:15, 9:30). Defaults to event duration."), + scheduleId: z + .number() + .nullable() + .optional() + .describe("Assign a specific availability schedule to this event type (by schedule ID)"), }), - execute: async ({ eventTypeId, title, slug, lengthInMinutes, description, hidden }) => { + execute: async ({ eventTypeId, ...rest }) => { const token = await getAccessTokenOrNull(teamId, userId); if (!token) return { error: "Account not connected." }; const patch = Object.fromEntries( - Object.entries({ title, slug, lengthInMinutes, description, hidden }).filter( - ([, v]) => v != null - ) + Object.entries(rest).filter(([, v]) => v != null) ); if (Object.keys(patch).length === 0) return { error: "No fields provided to update." }; try { @@ -834,7 +1871,7 @@ function createCalTools(teamId: string, userId: string, lookupPlatformUser?: Loo }), delete_event_type: tool({ - description: "Delete an event type by ID.", + description: "Delete an event type by ID. This is irreversible -- always confirm with the user first.", inputSchema: z.object({ eventTypeId: z.number().describe("The event type ID to delete"), }), @@ -862,8 +1899,13 @@ export function isAIToolCallError(err: unknown): boolean { msg.includes("failed to call a function") || msg.includes("failed_generation") || msg.includes("invalid_request_error") || + msg.includes("tool call validation failed") || + msg.includes("which was not in request.tools") || + msg.includes("tool choice is none") || causeMsg.includes("failed to call a function") || - causeMsg.includes("failed_generation") + causeMsg.includes("failed_generation") || + causeMsg.includes("tool call validation failed") || + causeMsg.includes("tool choice is none") ); } @@ -884,6 +1926,8 @@ export function isAIRateLimitError(err: unknown): boolean { return hasRateLimit || (status429 && (msg.includes("retry") || causeMsg.includes("retry"))); } +// ─── Agent stream ───────────────────────────────────────────────────────────── + export interface AgentStreamOptions { teamId: string; userId: string; @@ -894,6 +1938,8 @@ export interface AgentStreamOptions { logger?: Logger; /** When set, rate-limit errors from the stream are stored here for the caller to surface a friendly message. */ onErrorRef?: { current: Error | null }; + /** Pre-verified user context from bot layer — injected into system prompt. */ + userContext?: UserContext; } export function runAgentStream({ @@ -905,42 +1951,73 @@ export function runAgentStream({ platform, logger, onErrorRef, + userContext, }: AgentStreamOptions) { - const tools = createCalTools(teamId, userId, lookupPlatformUser); + const tools = createCalTools(teamId, userId, platform, lookupPlatformUser); // Keep only the last 10 messages from history to prevent stale context // (e.g. an old booking request) from hijacking unrelated follow-up messages. - const recentHistory = (conversationHistory ?? []).slice(-10); + const recentHistory = (conversationHistory ?? []).slice(-MAX_HISTORY_MESSAGES); const messages: ModelMessage[] = [ ...recentHistory, { role: "user" as const, content: userMessage }, ]; - // A full booking flow needs: check_account_linked + list_event_types + - // check_availability + (optionally a second check) + book_meeting + final reply = 6+ steps. - // Keep a hard cap to prevent runaway loops, but give enough room for complex flows. - const MAX_STEPS = 10; + // With pre-resolution, user context injection, and tool result persistence, + // the agent should need at most 3-4 steps per request. Keep a hard cap as safety net. + + // ─── Loop guard ─────────────────────────────────────────────────────────── + // Track tool calls across steps. If the same tool is called 2+ times with + // identical arguments, force a text response to break the loop. + const toolCallTracker = new Map(); const result = streamText({ model: getModel(), - system: getSystemPrompt(platform), + system: getSystemPrompt(platform, userContext), messages, tools, toolChoice: "auto", - stopWhen: stepCountIs(MAX_STEPS), - prepareStep({ stepNumber }) { + stopWhen: stepCountIs(MAX_AGENT_STEPS), + prepareStep({ stepNumber, steps: previousSteps }) { + // Track tool calls from all previous steps for loop detection + toolCallTracker.clear(); + for (const prev of previousSteps) { + if (!prev.toolCalls || prev.toolCalls.length === 0) continue; + for (const tc of prev.toolCalls) { + const input = "input" in tc ? tc.input : undefined; + const key = `${tc.toolName}:${JSON.stringify(input)}`; + toolCallTracker.set(key, (toolCallTracker.get(key) ?? 0) + 1); + } + } + const hasLoop = [...toolCallTracker.values()].some( + (count) => count >= 2 + ); + if (hasLoop) { + logger?.warn("Loop detected, forcing text response", { + tracker: Object.fromEntries(toolCallTracker), + }); + return { toolChoice: "none" as const }; + } // On the final allowed step, force a text response so the model // cannot keep calling tools indefinitely. - if (stepNumber === MAX_STEPS - 1) { - return { toolChoice: "none" }; + if (stepNumber === MAX_AGENT_STEPS - 1) { + return { toolChoice: "none" as const }; } return {}; }, onError({ error }) { logger?.error("Stream error", error); - if (onErrorRef && (isAIRateLimitError(error) || isAIToolCallError(error))) - onErrorRef.current = error instanceof Error ? error : new Error(String(error)); + if (onErrorRef) { + if (error instanceof Error) { + onErrorRef.current = error; + } else if (typeof error === "object" && error !== null && "message" in error) { + const msg = (error as { message: string }).message; + onErrorRef.current = new Error(msg); + } else { + onErrorRef.current = new Error(String(error)); + } + } }, onStepFinish({ finishReason, toolCalls, text }) { logger?.info("Step finished", { diff --git a/apps/chat/lib/bot.ts b/apps/chat/lib/bot.ts index 5e3e675..fb7f663 100644 --- a/apps/chat/lib/bot.ts +++ b/apps/chat/lib/bot.ts @@ -25,8 +25,9 @@ import { NotImplementedError, RateLimitError, } from "chat"; -import type { LookupPlatformUserFn } from "./agent"; +import type { LookupPlatformUserFn, UserContext } from "./agent"; import { isAIRateLimitError, isAIToolCallError, runAgentStream } from "./agent"; +import { CalcomApiError } from "./calcom/client"; import { generateAuthUrl } from "./calcom/oauth"; import { validateRequiredEnv } from "./env"; import { formatForTelegram } from "./format-for-telegram"; @@ -34,10 +35,16 @@ import { RETRY_STOP_BLOCKS, registerSlackHandlers } from "./handlers/slack"; import { registerTelegramHandlers } from "./handlers/telegram"; import { logger as botLogger } from "./logger"; import { helpCard, telegramHelpCard } from "./notifications"; -import { getLinkedUser } from "./user-linking"; +import { getLinkedUser, getValidAccessToken, getToolContext, setToolContext, isOrgPlanUser, type ToolContextEntry } from "./user-linking"; validateRequiredEnv(); +// ─── Named constants ──────────────────────────────────────────────────────── +const MAX_THREAD_MESSAGES = 20; +const MAX_TOOL_CONTEXT_ENTRIES = 10; +const STREAMING_UPDATE_INTERVAL_MS = 400; +const TELEGRAM_TYPING_REFRESH_MS = 4000; + // ─── Slack user lookup via users.info API ──────────────────────────────────── function makeLookupSlackUser(teamId: string): LookupPlatformUserFn { @@ -132,7 +139,7 @@ if (!globalForBot._chatBot) { userName: "calcom", adapters, state, - streamingUpdateIntervalMs: 400, // Tuned for both Slack and Telegram fallback post+edit + streamingUpdateIntervalMs: STREAMING_UPDATE_INTERVAL_MS, // Tuned for both Slack and Telegram fallback post+edit logger, }); @@ -215,7 +222,7 @@ async function buildHistory(thread: Thread): Promise { try { const collected: ModelMessage[] = []; for await (const msg of thread.messages) { - if (collected.length >= 20) break; + if (collected.length >= MAX_THREAD_MESSAGES) break; if (msg.text.trim()) { collected.push({ role: (msg.author.isMe ? "assistant" : "user") as "assistant" | "user", @@ -235,21 +242,244 @@ function isAsideMessage(text: string): boolean { return /^\s*aside\b/i.test(text); } -function handleSlackAuthError(err: unknown): boolean { +// ─── Helper: normalize Slack-mangled links in message text ────────────────── +// Slack auto-converts emails to and URLs to +// . The Chat SDK strips the angle brackets but leaves the +// raw content, so we get "mailto:email@x.com|email@x.com" in message.text. +// This normalizes those back to plain email addresses and URLs. +function normalizeSlackText(text: string): string { + // mailto:email@x.com|email@x.com → email@x.com + return text.replace(/mailto:[^\s|]+\|([^\s]+)/g, "$1"); +} + +// ─── Helper: reconstruct <@USER_ID> mentions from Slack raw blocks ─────────── +// The Chat SDK resolves <@U0AC2LAL3PF> to "@displayname" in message.text. +// This loses the user ID, which is the only thing lookup_platform_user can use. +// We rebuild the original <@USER_ID> format by walking the Slack rich_text blocks. +type SlackRichTextElement = + | { type: "text"; text: string } + | { type: "user"; user_id: string } + | { type: "link"; url: string; text?: string } + | { type: string; [key: string]: unknown }; + +type SlackBlock = + | { type: "rich_text"; elements: Array<{ type: string; elements?: SlackRichTextElement[] }> } + | { type: string; [key: string]: unknown }; + +function reconstructSlackMentions(raw: unknown): string | null { + if (!raw || typeof raw !== "object") return null; + const r = raw as Record; + const blocks = r.blocks as SlackBlock[] | undefined; + if (!Array.isArray(blocks)) return null; + + const parts: string[] = []; + for (const block of blocks) { + if (block.type !== "rich_text") continue; + const richBlock = block as { type: "rich_text"; elements: Array<{ type: string; elements?: SlackRichTextElement[] }> }; + for (const section of richBlock.elements) { + if (!Array.isArray(section.elements)) continue; + for (const el of section.elements) { + if (el.type === "text") { + parts.push((el as { type: "text"; text: string }).text); + } else if (el.type === "user") { + parts.push(`<@${(el as { type: "user"; user_id: string }).user_id}>`); + } else if (el.type === "link") { + const linkEl = el as { type: "link"; url: string; text?: string }; + // mailto: links → plain email + if (linkEl.url.startsWith("mailto:")) { + parts.push(linkEl.url.slice("mailto:".length)); + } else { + parts.push(linkEl.text ?? linkEl.url); + } + } + } + } + } + + const result = parts.join("").trim(); + return result.length > 0 ? result : null; +} + +// ─── Helper: pre-resolve Slack @mentions to name + email ───────────────────── +// Extracts <@USER_ID> patterns from the message, resolves each via Slack API, +// and prepends a [Context: @mentions resolved] block to the user message. +async function preResolveMentions( + userMessage: string, + teamId: string, + logger: ReturnType +): Promise<{ text: string; attendees: Array<{ uid: string; name: string; email: string | null }> }> { + const mentionPattern = /<@([A-Z0-9]+)>/g; + const matches = [...userMessage.matchAll(mentionPattern)]; + if (matches.length === 0) return { text: userMessage, attendees: [] }; + + const uniqueIds = [...new Set(matches.map((m) => m[1]))]; + const lookup = makeLookupSlackUser(teamId); + + const results = await Promise.all( + uniqueIds.map(async (uid) => { + try { + const profile = await lookup(uid); + if (!profile) return null; + return { uid, name: profile.realName ?? profile.name, email: profile.email ?? null }; + } catch { + return null; + } + }) + ); + + const resolved = results.filter( + (r): r is { uid: string; name: string; email: string | null } => r !== null + ); + if (resolved.length === 0) return { text: userMessage, attendees: [] }; + + const contextLines = resolved.map((r) => + r.email + ? `- <@${r.uid}> = "${r.name}" <${r.email}>` + : `- <@${r.uid}> = "${r.name}" (email not visible)` + ); + + logger.info("Pre-resolved mentions", { resolved: resolved.map((r) => r.uid) }); + + return { + text: `[Context: @mentions resolved]\n${contextLines.join("\n")}\n[End context]\n\n${userMessage}`, + attendees: resolved, + }; +} + +// ─── Helper: build UserContext from linked user data ───────────────────────── +function buildUserContext(linked: { + calcomEmail: string; + calcomUsername: string; + calcomTimeZone: string; +}): UserContext { + return { + calcomEmail: linked.calcomEmail, + calcomUsername: linked.calcomUsername, + calcomTimeZone: linked.calcomTimeZone, + }; +} + +// ─── Helper: persist booking context (ASAP intent + resolved attendees) ────── +const ASAP_PATTERN = /\b(asap|as soon as possible|earliest|next available|soonest)\b/i; + +async function persistBookingContext( + threadId: string, + userMessage: string, + attendees: Array<{ uid: string; name: string; email: string | null }> +): Promise { + const entries: ToolContextEntry[] = []; + + if (ASAP_PATTERN.test(userMessage)) { + entries.push({ + toolName: "_booking_intent", + args: {}, + result: { urgency: "asap" }, + timestamp: Date.now(), + }); + } + + if (attendees.length > 0) { + entries.push({ + toolName: "_resolved_attendees", + args: {}, + result: { + attendees: attendees.map((a) => ({ + name: a.name, + email: a.email, + slackUserId: a.uid, + })), + }, + timestamp: Date.now(), + }); + } + + if (entries.length === 0) return; + + const existing = await getToolContext(threadId); + const merged = [...existing, ...entries].slice(-MAX_TOOL_CONTEXT_ENTRIES); + await setToolContext(threadId, merged); +} + +// ─── Helper: build enriched user message with cached tool data ─────────────── +async function buildEnrichedMessage( + userMessage: string, + threadId: string +): Promise { + const entries = await getToolContext(threadId); + if (entries.length === 0) return userMessage; + + const latestByTool = new Map(); + for (const e of entries) { + latestByTool.set(e.toolName, e); + } + + const summary = [...latestByTool.values()] + .map((e) => `${e.toolName}: ${JSON.stringify(e.result)}`) + .join("\n"); + + return `[CACHED TOOL DATA - DO NOT re-call these tools]\n${summary}\n[END CACHED DATA]\n\n${userMessage}`; +} + +function isSlackAuthError(err: unknown): boolean { if ( err instanceof Error && "code" in err && (err as Record).code === "slack_webapi_platform_error" ) { const slackErr = (err as Record).data as Record | undefined; - if (slackErr?.error === "not_authed" || slackErr?.error === "invalid_auth") { - botLogger.error("Slack auth error — workspace may need reinstall", err); - return true; - } + return slackErr?.error === "not_authed" || slackErr?.error === "invalid_auth"; } return false; } +function friendlyCalcomError(err: CalcomApiError, context?: string): string { + const apiDetail = err.message && !err.message.startsWith("Cal.com API error:") + ? ` (${err.message})` + : ""; + + switch (err.statusCode) { + case 400: + return `The request had invalid data${apiDetail}. Please check the details and try again.`; + case 401: + case 403: + return "Your Cal.com session has expired. Please reconnect your account to continue."; + case 404: + return `The requested resource was not found${apiDetail}. It may have been deleted or the ID is incorrect.`; + case 409: + return context === "booking" + ? "This time slot is no longer available \u2014 someone else may have just booked it. Would you like me to check for other available times?" + : `There was a conflict${apiDetail}. The resource may have been modified. Please try again.`; + case 422: + return `The request was missing required information${apiDetail}. Please provide all required fields and try again.`; + case 429: + return "Cal.com's rate limit has been reached. Please wait about 30 seconds and try again."; + default: + if (err.statusCode && err.statusCode >= 500) { + return err.code === "FETCH_RETRY_EXHAUSTED" + ? "Cal.com is not responding after multiple attempts. The service may be temporarily down \u2014 please try again in a few minutes." + : "Cal.com is experiencing issues right now. Please try again in a moment."; + } + return `Something went wrong with the Cal.com API${apiDetail}. Please try again.`; + } +} + +function agentStreamErrorMessage( + lastStreamErrorRef: { current: Error | null } +): (err: unknown) => string | undefined { + return (err) => { + if (!lastStreamErrorRef.current) return undefined; + if (isAIRateLimitError(lastStreamErrorRef.current)) + return "I've hit my daily token limit. Please try again later when the limit resets."; + if (isAIToolCallError(lastStreamErrorRef.current)) + return "I had trouble processing that request. Please try again, or be more specific (e.g. run /cal bookings first, then cancel by booking ID)."; + if (lastStreamErrorRef.current instanceof CalcomApiError) + return friendlyCalcomError(lastStreamErrorRef.current); + if (isSlackAuthError(err)) + return "Sorry, something went wrong while processing your request. Please try again."; + return undefined; + }; +} + /** Wraps async handlers with consistent LockError, RateLimitError, adapter errors, and Slack auth error handling. */ async function withBotErrorHandling( fn: () => Promise, @@ -316,7 +546,6 @@ async function withBotErrorHandling( botLogger.warn("Feature not supported", err.feature); return; } - if (handleSlackAuthError(err)) return; // AI/LLM rate limit (e.g. Groq tokens-per-day) — show friendly message if (isAIRateLimitError(err)) { botLogger.warn("AI rate limit", err); @@ -335,10 +564,38 @@ async function withBotErrorHandling( .catch(() => {}); return; } - botLogger.error(options.logContext ? `Error in ${options.logContext}` : "Error", err); + // Check for a custom error message from the handler (e.g. captured AI stream error) + // before falling through to generic handlers — callers can provide context-specific + // messages (e.g. friendlyCalcomError with "booking" context for 409 conflicts). const customMsg = options.getCustomErrorMessage?.(err); + if (customMsg) { + botLogger.error(options.logContext ? `Error in ${options.logContext}` : "Error", err); + await options.postError(customMsg).catch((postErr) => { + botLogger.error("Failed to post error message to user", { postErr, originalErr: err }); + }); + return; + } + // Cal.com API errors — show status-specific messages (after custom handler so + // callers like the booking modal can provide context-aware messages first) + if (err instanceof CalcomApiError) { + botLogger.warn("Cal.com API error", { statusCode: err.statusCode, message: err.message }); + await options + .postError(friendlyCalcomError(err)) + .catch(() => {}); + return; + } + if (isSlackAuthError(err)) { + botLogger.error("Slack auth error — workspace may need reinstall", err); + await options + .postError( + "Sorry, I'm having trouble connecting to Slack right now. If this persists, please ask your workspace admin to reinstall the Cal.com app." + ) + .catch(() => {}); + return; + } + botLogger.error(options.logContext ? `Error in ${options.logContext}` : "Error", err); await options - .postError(customMsg ?? "Sorry, something went wrong. Please try again.") + .postError("Sorry, something went wrong. Please try again.") .catch((postErr) => { botLogger.error("Failed to post error message to user", { postErr, originalErr: err }); }); @@ -347,10 +604,13 @@ async function withBotErrorHandling( // ─── Helper: post OAuth link prompt ───────────────────────────────────────── -function oauthLinkMessage(platform: string, teamId: string, userId: string) { +function oauthLinkMessage(platform: string, teamId: string, userId: string, reason?: "expired" | "not_linked") { const authUrl = generateAuthUrl(platform, teamId, userId); + const title = reason === "expired" + ? "Your Cal.com Session Has Expired" + : "Connect Your Cal.com Account"; return Card({ - title: "Connect Your Cal.com Account", + title, children: [ Actions([ LinkButton({ @@ -362,6 +622,29 @@ function oauthLinkMessage(platform: string, teamId: string, userId: string) { }); } +async function promptUserToLink( + thread: Thread, + author: Parameters[0], + ctx: PlatformContext, + reason: "expired" | "not_linked" = "not_linked" +): Promise { + const card = oauthLinkMessage(ctx.platform, ctx.teamId, ctx.userId, reason); + if (ctx.platform === "telegram") { + const isGroup = thread.id !== `telegram:${ctx.userId}`; + if (isGroup) { + await thread.post("Please check your DMs to connect your Cal.com account."); + await thread.postEphemeral(author, card, { fallbackToDM: true }); + } else { + await thread.post(card); + } + } else { + // Ephemeral messages are unreliable in Slack assistant threads, so post as a + // regular thread message. The OAuth URL is user-specific and safe to show. + // Wrap with withSlackToken to ensure the bot token is in context. + await withSlackToken(ctx.teamId, () => thread.post(card)); + } +} + function getSlackAdapter(): SlackAdapter { return bot.getAdapter("slack") as SlackAdapter; } @@ -381,7 +664,7 @@ async function withTelegramTypingRefresh( await thread.startTyping(); if (platform !== "telegram") return fn(); return (async () => { - const interval = setInterval(() => thread.startTyping().catch(() => {}), 4000); + const interval = setInterval(() => thread.startTyping().catch(() => {}), TELEGRAM_TYPING_REFRESH_MS); try { await fn(); } finally { @@ -390,6 +673,20 @@ async function withTelegramTypingRefresh( })(); } +/** + * Run an async function with the Slack bot token explicitly set in context. + * Prevents `not_authed` errors when AsyncLocalStorage context is lost across + * async boundaries (e.g. Vercel waitUntil, long-running agent streams). + */ +async function withSlackToken(teamId: string, fn: () => Promise): Promise { + const slack = getSlackAdapter(); + const installation = await slack.getInstallation(teamId); + if (installation?.botToken) { + return slack.withBotToken(installation.botToken, fn); + } + return fn(); +} + // Streaming flow (see chat/docs/streaming.mdx): Slack uses the native chatStream API via // adapter.stream() with stopBlocks for the Retry button; other platforms use the post+edit // fallback via thread.post(textStream). @@ -397,7 +694,11 @@ async function withTelegramTypingRefresh( // textStream can yield empty when used with multi-step tool calls; result.text resolves to the full text. async function postAgentStream( thread: Thread, - agentResult: { textStream: AsyncIterable; text: PromiseLike }, + agentResult: { + textStream: AsyncIterable; + text: PromiseLike; + steps?: PromiseLike; + }, ctx: PlatformContext, options?: { onErrorRef?: { current: Error | null } } ): Promise { @@ -411,12 +712,14 @@ async function postAgentStream( try { if (ctx.platform === "slack") { const slack = getSlackAdapter(); - await slack.stream(thread.id, agentResult.textStream, { - recipientUserId: ctx.userId, - recipientTeamId: ctx.teamId, - stopBlocks: RETRY_STOP_BLOCKS, + await withSlackToken(ctx.teamId, async () => { + await slack.stream(thread.id, agentResult.textStream, { + recipientUserId: ctx.userId, + recipientTeamId: ctx.teamId, + stopBlocks: RETRY_STOP_BLOCKS, + }); + log.info("Slack stream completed", { userId: ctx.userId }); }); - log.info("Slack stream completed", { userId: ctx.userId }); } else { if (ctx.platform === "telegram") { const fullText = await agentResult.text; @@ -444,6 +747,43 @@ async function postAgentStream( } log.info("Stream posted", { platform: ctx.platform, userId: ctx.userId }); } + + // Persist tool results to Redis for subsequent turns in this thread + try { + if (!agentResult.steps) return; + const steps = await agentResult.steps; + const toolEntries: ToolContextEntry[] = []; + for (const step of steps) { + const s = step as Record; + const toolResults = s.toolResults; + if (!Array.isArray(toolResults)) continue; + for (const tr of toolResults) { + const r = tr as Record; + if (typeof r.toolName === "string" && r.args && r.result !== undefined) { + toolEntries.push({ + toolName: r.toolName, + args: r.args as Record, + result: r.result, + timestamp: Date.now(), + }); + } + } + } + if (toolEntries.length > 0) { + // Merge with existing context (keep last 10 entries to avoid unbounded growth) + const existing = await getToolContext(thread.id); + const merged = [...existing, ...toolEntries].slice(-MAX_TOOL_CONTEXT_ENTRIES); + await setToolContext(thread.id, merged); + log.info("Tool context persisted", { + threadId: thread.id, + newEntries: toolEntries.length, + totalEntries: merged.length, + }); + } + } catch (persistErr) { + // Non-fatal: tool context persistence failure should not break the response + log.warn("Failed to persist tool context", { err: persistErr, threadId: thread.id }); + } } catch (err) { log.error("Stream failed", { err, @@ -451,10 +791,113 @@ async function postAgentStream( userId: ctx.userId, threadId: thread.id, }); + // If the thrown error is a Slack auth error but the agent stream itself had + // issues (captured via onErrorRef), the Slack error is a secondary failure + // caused by AsyncLocalStorage context loss — not a real token problem. + // Re-throw as a generic error so withBotErrorHandling doesn't misclassify it. + if (isSlackAuthError(err) && options?.onErrorRef?.current) { + log.warn("Slack auth error is secondary to stream error, re-throwing as generic", { + streamError: options.onErrorRef.current.message, + }); + throw new Error( + `Agent stream failed: ${options.onErrorRef.current.message}` + ); + } throw err; } } +// ─── Shared agent handler ──────────────────────────────────────────────────── + +interface AgentHandlerOptions { + thread: Thread; + ctx: PlatformContext; + userMessage: string; + loggerName: string; + lastStreamErrorRef: { current: Error | null }; + promptIfNotLinked: (reason?: "expired") => Promise; + shouldSubscribe: () => boolean | Promise; + resolveMentions: boolean; + persistContext: boolean; +} + +async function runAgentHandler(opts: AgentHandlerOptions): Promise { + const log = bot.getLogger(opts.loggerName); + + let linked = await getLinkedUser(opts.ctx.teamId, opts.ctx.userId); + log.info("User link check", { userId: opts.ctx.userId, teamId: opts.ctx.teamId, linked: !!linked }); + if (!linked) { + await opts.promptIfNotLinked(); + return; + } + + const token = await getValidAccessToken(opts.ctx.teamId, opts.ctx.userId); + if (!token) { + log.warn("Token expired/revoked", { userId: opts.ctx.userId }); + await opts.promptIfNotLinked("expired"); + return; + } + + // Re-read in case token refresh synced org fields from /v2/me + linked = await getLinkedUser(opts.ctx.teamId, opts.ctx.userId) ?? linked; + + if (!isOrgPlanUser(linked)) { + log.info("Agentic blocked — not on org plan", { userId: opts.ctx.userId }); + const upgradeMsg = opts.ctx.platform === "slack" + ? "The AI assistant is available on the Cal.com Organizations plan. Use `/cal help` to see available slash commands, or upgrade at ." + : "The AI assistant is available on the Cal.com Organizations plan. Use /help to see available commands, or upgrade at [cal.com/pricing](https://cal.com/pricing)."; + if (opts.ctx.platform === "slack") { + await withSlackToken(opts.ctx.teamId, () => opts.thread.post(upgradeMsg)); + } else { + await opts.thread.post(upgradeMsg); + } + return; + } + + if (await opts.shouldSubscribe()) { + if (!(await opts.thread.isSubscribed())) { + await opts.thread.subscribe(); + } + } + + const userContext = buildUserContext(linked); + + let resolvedMessage = opts.userMessage; + if (opts.resolveMentions && opts.ctx.platform === "slack") { + const { text, attendees } = await preResolveMentions( + opts.userMessage, opts.ctx.teamId, log + ); + resolvedMessage = text; + if (opts.persistContext) { + await persistBookingContext(opts.thread.id, opts.userMessage, attendees); + } + } else if (opts.persistContext && ASAP_PATTERN.test(opts.userMessage)) { + await persistBookingContext(opts.thread.id, opts.userMessage, []); + } + + const enrichedMessage = await buildEnrichedMessage(resolvedMessage, opts.thread.id); + const rawHistory = await buildHistory(opts.thread); + + await withTelegramTypingRefresh(opts.thread, opts.ctx.platform, async () => { + log.info("Running agent", { userId: opts.ctx.userId, textLength: enrichedMessage.length }); + const result = runAgentStream({ + teamId: opts.ctx.teamId, + userId: opts.ctx.userId, + userMessage: enrichedMessage, + conversationHistory: rawHistory.slice(0, -1), + lookupPlatformUser: + opts.ctx.platform === "slack" ? makeLookupSlackUser(opts.ctx.teamId) : undefined, + platform: opts.ctx.platform, + logger: bot.getLogger("agent"), + onErrorRef: opts.lastStreamErrorRef, + userContext, + }); + await postAgentStream(opts.thread, result, opts.ctx, { + onErrorRef: opts.lastStreamErrorRef, + }); + }); +} + // ─── Telegram commands (/start, /help, /link, /unlink; /cal as Slack alias) ─── registerTelegramHandlers(bot, { @@ -491,49 +934,21 @@ bot.onNewMessage(/[\s\S]+/, async (thread, message) => { const lastStreamErrorRef = { current: null as Error | null }; await withBotErrorHandling( - async () => { - const linked = await getLinkedUser(ctx.teamId, ctx.userId); - bot - .getLogger("telegram-freeform") - .info("User link check", { userId: ctx.userId, linked: !!linked }); - if (!linked) { - await thread.post(oauthLinkMessage(ctx.platform, ctx.teamId, ctx.userId)); - return; - } - - if (!(await thread.isSubscribed())) { - await thread.subscribe(); - } - - const history = await buildHistory(thread); - await withTelegramTypingRefresh(thread, ctx.platform, async () => { - bot - .getLogger("telegram-freeform") - .info("Running agent", { userId: ctx.userId, textLength: message.text.length }); - const result = runAgentStream({ - teamId: ctx.teamId, - userId: ctx.userId, - userMessage: message.text, - conversationHistory: history.slice(0, -1), - lookupPlatformUser: undefined, - platform: ctx.platform, - logger: bot.getLogger("agent"), - onErrorRef: lastStreamErrorRef, - }); - await postAgentStream(thread, result, ctx, { onErrorRef: lastStreamErrorRef }); - }); - }, + () => runAgentHandler({ + thread, + ctx, + userMessage: message.text, + loggerName: "telegram-freeform", + lastStreamErrorRef, + promptIfNotLinked: async (reason) => { await thread.post(oauthLinkMessage(ctx.platform, ctx.teamId, ctx.userId, reason)); }, + shouldSubscribe: () => true, + resolveMentions: false, + persistContext: true, + }), { postError: (msg) => thread.post(msg).catch(() => {}), logContext: "telegram freeform", - getCustomErrorMessage: () => { - if (!lastStreamErrorRef.current) return undefined; - if (isAIRateLimitError(lastStreamErrorRef.current)) - return "I've hit my daily token limit. Please try again later when the limit resets."; - if (isAIToolCallError(lastStreamErrorRef.current)) - return "I had trouble processing that request. Please try again, or be more specific (e.g. run /cal bookings first, then cancel by booking ID)."; - return undefined; - }, + getCustomErrorMessage: agentStreamErrorMessage(lastStreamErrorRef), } ); }); @@ -550,10 +965,14 @@ bot.onNewMention(async (thread, message) => { const ctx = extractContext(thread, message); - // Strip leading @botname that Telegram includes in message.text for group @mentions. - // Other platforms (Slack) strip mentions automatically, so this only affects Telegram. + // For Slack: reconstruct <@USER_ID> mentions from raw blocks (the SDK resolves + // them to display names in message.text, losing the user ID lookup_platform_user needs). + // Also handles mailto: email links. Falls back to normalizeSlackText if no blocks. + // For Telegram: strip the leading @botname the adapter doesn't strip automatically. const userMessage = - ctx.platform === "telegram" ? message.text.replace(/^@\S+\s*/, "").trim() : message.text; + ctx.platform === "telegram" + ? message.text.replace(/^@\S+\s*/, "").trim() + : (reconstructSlackMentions(message.raw) ?? normalizeSlackText(message.text)); bot.getLogger("mention").info("New mention", { platform: ctx.platform, @@ -563,6 +982,11 @@ bot.onNewMention(async (thread, message) => { }); const lastStreamErrorRef = { current: null as Error | null }; + const safePost = (msg: string) => + ctx.platform === "slack" + ? withSlackToken(ctx.teamId, () => thread.post(msg)).catch(() => {}) + : thread.post(msg).catch(() => {}); + await withBotErrorHandling( async () => { // Telegram commands handled by onNewMessage (slash command handler) @@ -572,84 +996,28 @@ bot.onNewMention(async (thread, message) => { ) return; - const linked = await getLinkedUser(ctx.teamId, ctx.userId); - bot - .getLogger("mention") - .info("User link check", { userId: ctx.userId, teamId: ctx.teamId, linked: !!linked }); - if (!linked) { - bot.getLogger("mention").warn("User not linked", { userId: ctx.userId }); - if (ctx.platform === "telegram") { - // Never expose the signed OAuth URL in a group — any member could click it and - // link their Cal.com account to the requester's Telegram ID. - const isGroup = thread.id !== `telegram:${ctx.userId}`; - if (isGroup) { - await thread.post("Please check your DMs to connect your Cal.com account."); - await thread.postEphemeral( - message.author, - oauthLinkMessage(ctx.platform, ctx.teamId, ctx.userId), - { fallbackToDM: true } - ); - } else { - await thread.post(oauthLinkMessage(ctx.platform, ctx.teamId, ctx.userId)); - } - } else { - try { - await thread.postEphemeral( - message.author, - oauthLinkMessage(ctx.platform, ctx.teamId, ctx.userId), - { fallbackToDM: true } - ); - } catch (ephemeralErr) { - bot - .getLogger("mention") - .error("Ephemeral post failed", { err: ephemeralErr, userId: ctx.userId }); - throw ephemeralErr; - } - } - return; - } - // Empty text means user only sent "@botname" with no additional message — show help. if (!userMessage) { await thread.post(ctx.platform === "telegram" ? telegramHelpCard() : helpCard()); return; } - if (!(await thread.isSubscribed())) { - await thread.subscribe(); - } - - const history = await buildHistory(thread); - - await withTelegramTypingRefresh(thread, ctx.platform, async () => { - bot - .getLogger("mention") - .info("Running agent", { userId: ctx.userId, textLength: userMessage.length }); - const result = runAgentStream({ - teamId: ctx.teamId, - userId: ctx.userId, - userMessage, - conversationHistory: history.slice(0, -1), - lookupPlatformUser: - ctx.platform === "slack" ? makeLookupSlackUser(ctx.teamId) : undefined, - platform: ctx.platform, - logger: bot.getLogger("agent"), - onErrorRef: lastStreamErrorRef, - }); - await postAgentStream(thread, result, ctx, { onErrorRef: lastStreamErrorRef }); + await runAgentHandler({ + thread, + ctx, + userMessage, + loggerName: "mention", + lastStreamErrorRef, + promptIfNotLinked: (reason) => promptUserToLink(thread, message.author, ctx, reason), + shouldSubscribe: () => true, + resolveMentions: true, + persistContext: true, }); }, { - postError: (msg) => thread.post(msg).catch(() => {}), + postError: safePost, logContext: "handling mention", - getCustomErrorMessage: () => { - if (!lastStreamErrorRef.current) return undefined; - if (isAIRateLimitError(lastStreamErrorRef.current)) - return "I've hit my daily token limit. Please try again later when the limit resets."; - if (isAIToolCallError(lastStreamErrorRef.current)) - return "I had trouble processing that request. Please try again, or be more specific (e.g. run /cal bookings first, then cancel by booking ID)."; - return undefined; - }, + getCustomErrorMessage: agentStreamErrorMessage(lastStreamErrorRef), } ); }); @@ -668,9 +1036,14 @@ bot.onSubscribedMessage(async (thread, message) => { const isTelegramGroup = ctx.platform === "telegram" && thread.id !== `telegram:${ctx.userId}`; if (isTelegramGroup && !message.isMention) return; - // Strip leading @botname for Telegram group @mentions (adapter doesn't strip automatically). + // For Slack: reconstruct <@USER_ID> mentions from raw blocks (the SDK resolves + // them to display names in message.text, losing the user ID lookup_platform_user needs). + // Also handles mailto: email links. Falls back to normalizeSlackText if no blocks. + // For Telegram: strip the leading @botname the adapter doesn't strip automatically. const userMessage = - ctx.platform === "telegram" ? message.text.replace(/^@\S+\s*/, "").trim() : message.text; + ctx.platform === "telegram" + ? message.text.replace(/^@\S+\s*/, "").trim() + : (reconstructSlackMentions(message.raw) ?? normalizeSlackText(message.text)); bot.getLogger("thread-follow-up").info("Thread follow-up", { platform: ctx.platform, @@ -681,83 +1054,27 @@ bot.onSubscribedMessage(async (thread, message) => { }); const lastStreamErrorRef = { current: null as Error | null }; - await withBotErrorHandling( - async () => { - const linked = await getLinkedUser(ctx.teamId, ctx.userId); - bot - .getLogger("thread-follow-up") - .info("User link check", { userId: ctx.userId, teamId: ctx.teamId, linked: !!linked }); - if (!linked) { - bot.getLogger("thread-follow-up").warn("User not linked", { userId: ctx.userId }); - if (ctx.platform === "telegram") { - // Never expose the signed OAuth URL in a group — any member could click it and - // link their Cal.com account to the requester's Telegram ID. - const isGroup = thread.id !== `telegram:${ctx.userId}`; - if (isGroup) { - await thread.post("Please check your DMs to connect your Cal.com account."); - await thread.postEphemeral( - message.author, - oauthLinkMessage(ctx.platform, ctx.teamId, ctx.userId), - { fallbackToDM: true } - ); - } else { - await thread.post(oauthLinkMessage(ctx.platform, ctx.teamId, ctx.userId)); - } - } else { - try { - await thread.postEphemeral( - message.author, - oauthLinkMessage(ctx.platform, ctx.teamId, ctx.userId), - { fallbackToDM: true } - ); - bot - .getLogger("thread-follow-up") - .info("Ephemeral OAuth link posted", { userId: ctx.userId }); - } catch (ephemeralErr) { - bot - .getLogger("thread-follow-up") - .error("Ephemeral post failed", { err: ephemeralErr, userId: ctx.userId }); - throw ephemeralErr; - } - } - return; - } - - if (message.isMention && !(await thread.isSubscribed())) { - await thread.subscribe(); - } + const safePost = (msg: string) => + ctx.platform === "slack" + ? withSlackToken(ctx.teamId, () => thread.post(msg)).catch(() => {}) + : thread.post(msg).catch(() => {}); - const history = await buildHistory(thread); - - await withTelegramTypingRefresh(thread, ctx.platform, async () => { - bot - .getLogger("thread-follow-up") - .info("Running agent", { userId: ctx.userId, textLength: userMessage.length }); - const result = runAgentStream({ - teamId: ctx.teamId, - userId: ctx.userId, - userMessage, - conversationHistory: history.slice(0, -1), - lookupPlatformUser: - ctx.platform === "slack" ? makeLookupSlackUser(ctx.teamId) : undefined, - platform: ctx.platform, - logger: bot.getLogger("agent"), - onErrorRef: lastStreamErrorRef, - }); - await postAgentStream(thread, result, ctx, { onErrorRef: lastStreamErrorRef }); - }); - }, + await withBotErrorHandling( + () => runAgentHandler({ + thread, + ctx, + userMessage, + loggerName: "thread-follow-up", + lastStreamErrorRef, + promptIfNotLinked: (reason) => promptUserToLink(thread, message.author, ctx, reason), + shouldSubscribe: () => !!(message.isMention), + resolveMentions: true, + persistContext: false, + }), { - postError: (msg) => thread.post(msg).catch(() => {}), + postError: safePost, logContext: "thread follow-up", - getCustomErrorMessage: () => { - if (!lastStreamErrorRef.current) return undefined; - if (isAIRateLimitError(lastStreamErrorRef.current)) - return "I've hit my daily token limit. Please try again later when the limit resets."; - if (isAIToolCallError(lastStreamErrorRef.current)) - return "I had trouble processing that request. Please try again, or be more specific (e.g. run /cal bookings first, then cancel by booking ID)."; - return undefined; - }, + getCustomErrorMessage: agentStreamErrorMessage(lastStreamErrorRef), } ); }); @@ -782,4 +1099,5 @@ registerSlackHandlers(bot, getSlackAdapter, { extractTeamIdFromRaw, buildHistory, makeLookupSlackUser, + friendlyCalcomError, }); diff --git a/apps/chat/lib/calcom/client.ts b/apps/chat/lib/calcom/client.ts index 4de5f06..56dc5f3 100644 --- a/apps/chat/lib/calcom/client.ts +++ b/apps/chat/lib/calcom/client.ts @@ -1,4 +1,5 @@ import type { + AddAttendeeInput, BusyTime, CalcomApiResponse, CalcomBooking, @@ -7,6 +8,7 @@ import type { CalcomSlot, CalendarLink, CreateBookingInput, + CreatePublicBookingInput, CreateEventTypeInput, CreateScheduleInput, SlotsResponse, @@ -18,6 +20,12 @@ import type { const CALCOM_API_URL = process.env.CALCOM_API_URL ?? "https://api.cal.com"; const API_VERSION = "2024-08-13"; +const FETCH_TIMEOUT_MS = 10_000; +const MAX_RETRIES = 2; +const RETRY_BASE_MS = 500; +const RETRY_MULTIPLIER = 3; +const RETRYABLE_STATUS_CODES = new Set([500, 502, 503, 504]); + export class CalcomApiError extends Error { constructor( message: string, @@ -29,14 +37,54 @@ export class CalcomApiError extends Error { } } +async function fetchWithRetry( + url: string, + init: RequestInit = {}, + maxRetries: number = MAX_RETRIES +): Promise { + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const res = await fetch(url, { + ...init, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + + if (RETRYABLE_STATUS_CODES.has(res.status) && attempt < maxRetries) { + await sleep(RETRY_BASE_MS * RETRY_MULTIPLIER ** attempt); + continue; + } + + return res; + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + if (attempt < maxRetries) { + await sleep(RETRY_BASE_MS * RETRY_MULTIPLIER ** attempt); + } + } + } + + throw new CalcomApiError( + `Cal.com API request failed after ${maxRetries + 1} attempts: ${lastError?.message ?? "unknown error"}`, + undefined, + "FETCH_RETRY_EXHAUSTED" + ); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + async function calcomFetch( path: string, accessToken: string, options: RequestInit = {}, - apiVersion: string = API_VERSION + apiVersion: string = API_VERSION, + retries: number = MAX_RETRIES ): Promise { const url = `${CALCOM_API_URL}${path}`; - const res = await fetch(url, { + const res = await fetchWithRetry(url, { ...options, headers: { "cal-api-version": apiVersion, @@ -44,7 +92,7 @@ async function calcomFetch( "Content-Type": "application/json", ...options.headers, }, - }); + }, retries); if (!res.ok) { let errorMessage = `Cal.com API error: ${res.status} ${res.statusText}`; @@ -85,12 +133,13 @@ export async function getEventType( accessToken: string, eventTypeId: number ): Promise { - return calcomFetch( + const raw = await calcomFetch( `/v2/event-types/${eventTypeId}`, accessToken, {}, "2024-06-14" ); + return { ...raw, length: raw.length ?? raw.lengthInMinutes ?? 0 }; } export interface GetSlotsParams { @@ -99,6 +148,7 @@ export interface GetSlotsParams { end: string; timeZone?: string; duration?: number; + bookingUidToReschedule?: string; } export async function getAvailableSlots( @@ -111,13 +161,69 @@ export async function getAvailableSlots( end: params.end, ...(params.timeZone ? { timeZone: params.timeZone } : {}), ...(params.duration ? { duration: String(params.duration) } : {}), + ...(params.bookingUidToReschedule ? { bookingUidToReschedule: params.bookingUidToReschedule } : {}), }); - const data = await calcomFetch(`/v2/slots?${query}`, accessToken); + const data = await calcomFetch(`/v2/slots?${query}`, accessToken, {}, "2024-09-04"); return data.slots; } +export interface GetPublicSlotsParams { + eventTypeSlug: string; + username: string; + start: string; + end: string; + timeZone?: string; + duration?: number; + bookingUidToReschedule?: string; +} + +interface PublicSlotEntry { + start: string; +} + +export async function getAvailableSlotsPublic( + params: GetPublicSlotsParams +): Promise> { + const query = new URLSearchParams({ + eventTypeSlug: params.eventTypeSlug, + username: params.username, + start: params.start, + end: params.end, + ...(params.timeZone ? { timeZone: params.timeZone } : {}), + ...(params.duration ? { duration: String(params.duration) } : {}), + ...(params.bookingUidToReschedule ? { bookingUidToReschedule: params.bookingUidToReschedule } : {}), + }); + const url = `${CALCOM_API_URL}/v2/slots?${query}`; + const res = await fetchWithRetry(url, { + headers: { + "cal-api-version": "2024-09-04", + "Content-Type": "application/json", + }, + }); + if (!res.ok) { + throw new CalcomApiError(`Failed to fetch public slots: ${res.status}`, res.status); + } + const json = (await res.json()) as { status: string; data: Record }; + if (json.status === "error") { + throw new CalcomApiError("Failed to fetch slots for this user", res.status); + } + const normalized: Record = {}; + for (const [date, slots] of Object.entries(json.data)) { + normalized[date] = slots.map((s) => ({ + time: s.start, + available: true, + })); + } + return normalized; +} + export interface GetBookingsParams { status?: "upcoming" | "recurring" | "past" | "cancelled" | "unconfirmed"; + attendeeEmail?: string; + attendeeName?: string; + afterStart?: string; + beforeEnd?: string; + sortStart?: "asc" | "desc"; take?: number; skip?: number; } @@ -134,6 +240,11 @@ export async function getBookings( ): Promise { const query = new URLSearchParams(); if (params.status) query.set("status", params.status); + if (params.attendeeEmail) query.set("attendeeEmail", params.attendeeEmail); + if (params.attendeeName) query.set("attendeeName", params.attendeeName); + if (params.afterStart) query.set("afterStart", params.afterStart); + if (params.beforeEnd) query.set("beforeEnd", params.beforeEnd); + if (params.sortStart) query.set("sortStart", params.sortStart); if (params.take) query.set("take", String(params.take)); if (params.skip) query.set("skip", String(params.skip)); const qs = query.toString() ? `?${query}` : ""; @@ -168,30 +279,67 @@ export async function createBooking( return calcomFetch("/v2/bookings", accessToken, { method: "POST", body: JSON.stringify(input), - }); + }, API_VERSION, 0); +} + +export async function createBookingPublic( + input: CreatePublicBookingInput +): Promise { + const url = `${CALCOM_API_URL}/v2/bookings`; + const res = await fetchWithRetry(url, { + method: "POST", + headers: { + "cal-api-version": "2024-08-13", + "Content-Type": "application/json", + }, + body: JSON.stringify(input), + }, 0); + if (!res.ok) { + const body = await res.text(); + let message = `Booking failed (${res.status})`; + try { + const parsed = JSON.parse(body); + message = parsed.error?.message ?? parsed.message ?? message; + } catch { /* use default */ } + throw new CalcomApiError(message, res.status); + } + const json = (await res.json()) as CalcomApiResponse; + if (json.status === "error") { + throw new CalcomApiError(json.error?.message ?? "Booking failed", undefined, json.error?.code); + } + return json.data; } export async function cancelBooking( accessToken: string, bookingUid: string, - reason?: string + reason?: string, + cancelSubsequentBookings?: boolean ): Promise { await calcomFetch(`/v2/bookings/${bookingUid}/cancel`, accessToken, { method: "POST", - body: JSON.stringify({ cancellationReason: reason }), - }); + body: JSON.stringify({ + cancellationReason: reason, + ...(cancelSubsequentBookings ? { cancelSubsequentBookings: true } : {}), + }), + }, API_VERSION, 0); } export async function rescheduleBooking( accessToken: string, bookingUid: string, newStart: string, - reason?: string + reason?: string, + rescheduledBy?: string ): Promise { return calcomFetch(`/v2/bookings/${bookingUid}/reschedule`, accessToken, { method: "POST", - body: JSON.stringify({ start: newStart, reschedulingReason: reason }), - }); + body: JSON.stringify({ + start: newStart, + reschedulingReason: reason, + ...(rescheduledBy ? { rescheduledBy } : {}), + }), + }, API_VERSION, 0); } export interface CalcomMe { @@ -200,6 +348,8 @@ export interface CalcomMe { email: string; name: string; timeZone: string; + organizationId: number | null; + organization?: { isPlatform: boolean; id: number }; } export async function getMe(accessToken: string): Promise { @@ -210,7 +360,7 @@ export async function updateMe(accessToken: string, input: UpdateMeInput): Promi return calcomFetch("/v2/me", accessToken, { method: "PATCH", body: JSON.stringify(input), - }); + }, API_VERSION, 0); } // ─── Schedules ─────────────────────────────────────────────────────────────── @@ -248,7 +398,8 @@ export async function createSchedule( method: "POST", body: JSON.stringify(input), }, - SCHEDULES_VERSION + SCHEDULES_VERSION, + 0 ); } @@ -264,7 +415,8 @@ export async function updateSchedule( method: "PATCH", body: JSON.stringify(input), }, - SCHEDULES_VERSION + SCHEDULES_VERSION, + 0 ); } @@ -275,7 +427,8 @@ export async function deleteSchedule(accessToken: string, scheduleId: number): P { method: "DELETE", }, - SCHEDULES_VERSION + SCHEDULES_VERSION, + 0 ); } @@ -287,7 +440,7 @@ export async function confirmBooking( ): Promise { return calcomFetch(`/v2/bookings/${bookingUid}/confirm`, accessToken, { method: "POST", - }); + }, API_VERSION, 0); } export async function declineBooking( @@ -298,7 +451,7 @@ export async function declineBooking( return calcomFetch(`/v2/bookings/${bookingUid}/decline`, accessToken, { method: "POST", body: JSON.stringify({ reason }), - }); + }, API_VERSION, 0); } // ─── Calendar busy times ────────────────────────────────────────────────────── @@ -329,7 +482,8 @@ export async function createEventType( method: "POST", body: JSON.stringify(input), }, - "2024-06-14" + "2024-06-14", + 0 ); } @@ -345,7 +499,8 @@ export async function updateEventType( method: "PATCH", body: JSON.stringify(input), }, - "2024-06-14" + "2024-06-14", + 0 ); } @@ -356,10 +511,47 @@ export async function deleteEventType(accessToken: string, eventTypeId: number): { method: "DELETE", }, - "2024-06-14" + "2024-06-14", + 0 ); } +// ─── Booking attendees ─────────────────────────────────────────────────────── + +export async function addBookingAttendee( + accessToken: string, + bookingUid: string, + input: AddAttendeeInput +): Promise { + await calcomFetch(`/v2/bookings/${bookingUid}/attendees`, accessToken, { + method: "POST", + body: JSON.stringify(input), + }, API_VERSION, 0); +} + +// ─── Public event types (no auth) ───────────────────────────────────────────── + +export async function getEventTypesByUsername(username: string): Promise { + const url = `${CALCOM_API_URL}/v2/event-types?username=${encodeURIComponent(username)}`; + const res = await fetchWithRetry(url, { + headers: { + "cal-api-version": "2024-06-14", + "Content-Type": "application/json", + }, + }); + if (!res.ok) { + throw new CalcomApiError(`Failed to fetch event types for ${username}`, res.status); + } + const json = (await res.json()) as CalcomApiResponse; + if (json.status === "error") { + throw new CalcomApiError(json.error?.message ?? "User not found", undefined, json.error?.code); + } + return (json.data ?? []).map((et) => ({ + ...et, + length: et.length ?? et.lengthInMinutes ?? 0, + })); +} + // ─── Booking extras ─────────────────────────────────────────────────────────── export async function getCalendarLinks( @@ -369,8 +561,17 @@ export async function getCalendarLinks( return calcomFetch(`/v2/bookings/${bookingUid}/calendar-links`, accessToken); } -export async function markNoShow(accessToken: string, bookingUid: string): Promise { +export async function markNoShow( + accessToken: string, + bookingUid: string, + host?: boolean, + attendees?: Array<{ email: string; absent: boolean }> +): Promise { await calcomFetch(`/v2/bookings/${bookingUid}/mark-absent`, accessToken, { method: "POST", - }); + body: JSON.stringify({ + ...(host !== undefined ? { host } : {}), + ...(attendees ? { attendees } : {}), + }), + }, API_VERSION, 0); } diff --git a/apps/chat/lib/calcom/types.ts b/apps/chat/lib/calcom/types.ts index 3953d4c..3822bd7 100644 --- a/apps/chat/lib/calcom/types.ts +++ b/apps/chat/lib/calcom/types.ts @@ -72,6 +72,7 @@ export interface CalcomBooking { meetingUrl: string | null; location: string | null; absentHost: boolean; + recurringBookingUid: string | null; } export interface CreateBookingInput { @@ -82,7 +83,23 @@ export interface CreateBookingInput { email: string; timeZone: string; }; - notes?: string; + guests?: string[]; + metadata?: Record; + bookingFieldsResponses?: Record; +} + +export interface CreatePublicBookingInput { + eventTypeSlug: string; + username: string; + start: string; // ISO 8601 UTC + attendee: { + name: string; + email: string; + timeZone: string; + }; + guests?: string[]; + lengthInMinutes?: number; + bookingFieldsResponses?: Record; metadata?: Record; } @@ -207,6 +224,11 @@ export interface CreateEventTypeInput { lengthInMinutes: number; description?: string; hidden?: boolean; + minimumBookingNotice?: number; + beforeEventBuffer?: number; + afterEventBuffer?: number; + slotInterval?: number; + scheduleId?: number; } export interface UpdateEventTypeInput { @@ -215,6 +237,11 @@ export interface UpdateEventTypeInput { lengthInMinutes?: number; description?: string; hidden?: boolean; + minimumBookingNotice?: number; + beforeEventBuffer?: number; + afterEventBuffer?: number; + slotInterval?: number; + scheduleId?: number; } // ─── Calendar Links ─────────────────────────────────────────────────────────── @@ -226,6 +253,14 @@ export interface CalendarLink { ics?: string; } +// ─── Add Attendee ───────────────────────────────────────────────────────────── + +export interface AddAttendeeInput { + name: string; + email: string; + timeZone: string; +} + // ─── Busy Times ─────────────────────────────────────────────────────────────── export interface BusyTime { diff --git a/apps/chat/lib/handlers/slack.ts b/apps/chat/lib/handlers/slack.ts index ad1f9ed..d99d06a 100644 --- a/apps/chat/lib/handlers/slack.ts +++ b/apps/chat/lib/handlers/slack.ts @@ -18,7 +18,7 @@ import { } from "chat"; import type { LookupPlatformUserFn } from "../agent"; import { isAIRateLimitError, isAIToolCallError, runAgentStream } from "../agent"; -import { createBooking, getAvailableSlots, getBookings, getEventTypes } from "../calcom/client"; +import { CalcomApiError, createBooking, getAvailableSlots, getBookings, getEventTypes } from "../calcom/client"; import { generateAuthUrl } from "../calcom/oauth"; import { formatBookingTime } from "../calcom/webhooks"; import { getLogger } from "../logger"; @@ -34,6 +34,7 @@ import { getBookingFlow, getLinkedUser, getValidAccessToken, + isOrgPlanUser, setBookingFlow, unlinkUser, } from "../user-linking"; @@ -41,6 +42,18 @@ import { const logger = getLogger("slack-handlers"); const CALCOM_APP_URL = process.env.CALCOM_APP_URL ?? "https://app.cal.com"; +function isSlackAuthError(err: unknown): boolean { + if ( + err instanceof Error && + "code" in err && + (err as Record).code === "slack_webapi_platform_error" + ) { + const slackErr = (err as Record).data as Record | undefined; + return slackErr?.error === "not_authed" || slackErr?.error === "invalid_auth"; + } + return false; +} + const RETRY_STOP_BLOCKS = cardToBlockKit( Card({ children: [Actions([Button({ id: "retry_response", label: "Retry" })])], @@ -78,6 +91,7 @@ export interface RegisterSlackHandlersDeps { extractTeamIdFromRaw: (raw: unknown, adapterName?: string) => string; buildHistory: (thread: Thread) => Promise; makeLookupSlackUser: (teamId: string) => LookupPlatformUserFn; + friendlyCalcomError: (err: import("../calcom/client").CalcomApiError, context?: string) => string; } function oauthLinkMessage(platform: string, teamId: string, userId: string) { @@ -107,6 +121,7 @@ export function registerSlackHandlers( extractTeamIdFromRaw, buildHistory, makeLookupSlackUser, + friendlyCalcomError, } = deps; async function safeChannelPost( @@ -242,15 +257,23 @@ export function registerSlackHandlers( type: "home", blocks: cardToBlockKit(homeCard), }); - } catch { + } catch (err) { + const isAuthError = err instanceof CalcomApiError && (err.statusCode === 401 || err.statusCode === 403); const authUrl = generateAuthUrl("slack", teamId, userId); - const errorCard = Card({ - title: "Could Not Load Bookings", - children: [ - CardText("Your session may have expired — please reconnect."), - Actions([LinkButton({ url: authUrl, label: "Reconnect Cal.com" })]), - ], - }); + const errorCard = isAuthError + ? Card({ + title: "Could Not Load Bookings", + children: [ + CardText("Your session may have expired — please reconnect."), + Actions([LinkButton({ url: authUrl, label: "Reconnect Cal.com" })]), + ], + }) + : Card({ + title: "Could Not Load Bookings", + children: [ + CardText("Could not load bookings. Please try again later."), + ], + }); await slack.publishHomeView(userId, { type: "home", blocks: cardToBlockKit(errorCard), @@ -499,6 +522,14 @@ export function registerSlackHandlers( const openModal = (event as { openModal?: (modal: unknown) => Promise }) .openModal; if (!openModal) { + if (!isOrgPlanUser(linked)) { + await event.channel.postEphemeral( + event.user, + "The AI assistant is available on the Cal.com Organizations plan. Use `/cal help` to see available slash commands, or upgrade at .", + { fallbackToDM: true } + ); + return; + } const result = runAgentStream({ teamId, userId, @@ -539,6 +570,24 @@ export function registerSlackHandlers( return; } + const linked = await getLinkedUser(teamId, userId); + if (!linked) { + await event.channel.postEphemeral( + event.user, + oauthLinkMessage("slack", teamId, userId), + { fallbackToDM: true } + ); + return; + } + if (!isOrgPlanUser(linked)) { + await event.channel.postEphemeral( + event.user, + "The AI assistant is available on the Cal.com Organizations plan. Use `/cal help` to see available slash commands, or upgrade at .", + { fallbackToDM: true } + ); + return; + } + const result = runAgentStream({ teamId, userId, @@ -556,12 +605,16 @@ export function registerSlackHandlers( { postError: (msg) => safeChannelPost(event, msg).catch(() => {}), logContext: "/cal", - getCustomErrorMessage: () => { + getCustomErrorMessage: (err) => { if (!lastStreamErrorRef.current) return undefined; if (isAIRateLimitError(lastStreamErrorRef.current)) return "I've hit my daily token limit. Please try again later when the limit resets."; if (isAIToolCallError(lastStreamErrorRef.current)) return "I had trouble processing that request. Please try again, or be more specific (e.g. run /cal bookings first, then cancel by booking ID)."; + if (lastStreamErrorRef.current instanceof CalcomApiError) + return friendlyCalcomError(lastStreamErrorRef.current); + if (isSlackAuthError(err)) + return "Sorry, something went wrong while processing your request. Please try again."; return undefined; }, } @@ -914,8 +967,12 @@ export function registerSlackHandlers( { postError: (msg) => thread.post(msg).catch(() => {}), logContext: "confirm_booking", - getCustomErrorMessage: (err) => - err instanceof Error ? `Failed to create booking: ${err.message}` : undefined, + getCustomErrorMessage: (err) => { + if (err instanceof CalcomApiError) { + return friendlyCalcomError(err, "booking"); + } + return err instanceof Error ? "Failed to create the booking. Please try again." : undefined; + }, } ); }); @@ -968,12 +1025,16 @@ export function registerSlackHandlers( { postError: (msg) => thread.post(msg).catch(() => {}), logContext: "retry_response", - getCustomErrorMessage: () => { + getCustomErrorMessage: (err) => { if (!lastStreamErrorRef.current) return undefined; if (isAIRateLimitError(lastStreamErrorRef.current)) return "I've hit my daily token limit. Please try again later when the limit resets."; if (isAIToolCallError(lastStreamErrorRef.current)) return "I had trouble processing that request. Please try again, or be more specific (e.g. run /cal bookings first, then cancel by booking ID)."; + if (lastStreamErrorRef.current instanceof CalcomApiError) + return friendlyCalcomError(lastStreamErrorRef.current); + if (isSlackAuthError(err)) + return "Sorry, something went wrong while processing your request. Please try again."; return undefined; }, } diff --git a/apps/chat/lib/user-linking.ts b/apps/chat/lib/user-linking.ts index 9f67f05..4110623 100644 --- a/apps/chat/lib/user-linking.ts +++ b/apps/chat/lib/user-linking.ts @@ -44,6 +44,12 @@ function decryptData(stored: string): string { return decipher.update(ciphertext) + decipher.final("utf8"); } +// ─── Named constants ──────────────────────────────────────────────────────── +const LINKED_USER_TTL_SECONDS = 60 * 60 * 24 * 365; +const BOOKING_FLOW_TTL_SECONDS = 60 * 30; +const TOOL_CONTEXT_TTL_SECONDS = 60 * 30; +const REFRESH_LOCK_WAIT_MS = 2000; + const logger = getLogger("user-linking"); // Atomically deletes an email index key only if its current value matches the expected owner. @@ -79,6 +85,17 @@ export interface LinkedUser { calcomUsername: string; calcomTimeZone: string; linkedAt: string; + calcomOrganizationId: number | null; + calcomOrgIsPlatform: boolean | null; +} + +/** + * Returns true if the linked user is on the Cal.com Organizations plan. + * Platform (API-tier) orgs and free/individual users return false. + * Existing Redis entries without org fields are treated as non-org (safe default). + */ +export function isOrgPlanUser(linked: LinkedUser): boolean { + return linked.calcomOrganizationId != null && linked.calcomOrgIsPlatform === false; } function userKey(teamId: string, userId: string): string { @@ -103,10 +120,10 @@ export async function linkUser(teamId: string, userId: string, data: LinkedUser) } await client.set(key, encryptData(JSON.stringify(data)), { - EX: 60 * 60 * 24 * 365, + EX: LINKED_USER_TTL_SECONDS, }); await client.set(emailIndexKey(data.calcomEmail), JSON.stringify({ teamId, userId }), { - EX: 60 * 60 * 24 * 365, + EX: LINKED_USER_TTL_SECONDS, }); logger.info("User linked", { teamId, userId, calcomEmail: data.calcomEmail }); } @@ -155,7 +172,7 @@ export async function isUserLinked(teamId: string, userId: string): Promise setTimeout(r, 2000)); + await new Promise((r) => setTimeout(r, REFRESH_LOCK_WAIT_MS)); const updated = await getLinkedUser(teamId, userId); if (!updated) return null; if (Date.now() < updated.tokenExpiresAt - TOKEN_REFRESH_BUFFER_MS) { @@ -192,11 +209,27 @@ export async function getValidAccessToken(teamId: string, userId: string): Promi const { refreshAccessToken } = await import("./calcom/oauth"); const tokens = await refreshAccessToken(linked.refreshToken); + let orgUpdate: Pick = { + calcomOrganizationId: linked.calcomOrganizationId, + calcomOrgIsPlatform: linked.calcomOrgIsPlatform, + }; + try { + const { getMe } = await import("./calcom/client"); + const me = await getMe(tokens.access_token); + orgUpdate = { + calcomOrganizationId: me.organizationId ?? null, + calcomOrgIsPlatform: me.organization?.isPlatform ?? null, + }; + } catch { + // Non-fatal: keep existing org data if /v2/me fails during refresh + } + const updatedUser: LinkedUser = { ...linked, accessToken: tokens.access_token, refreshToken: tokens.refresh_token, tokenExpiresAt: Date.now() + tokens.expires_in * 1000, + ...orgUpdate, }; await linkUser(teamId, userId, updatedUser); @@ -236,7 +269,7 @@ export async function setBookingFlow( ): Promise { const client = getRedisClient(); const key = `calcom:booking_flow:${teamId}:${userId}`; - await client.set(key, JSON.stringify(state), { EX: 60 * 30 }); + await client.set(key, JSON.stringify(state), { EX: BOOKING_FLOW_TTL_SECONDS }); } export async function getBookingFlow( @@ -259,6 +292,40 @@ export async function clearBookingFlow(teamId: string, userId: string): Promise< await client.del(`calcom:booking_flow:${teamId}:${userId}`); } +// ─── Tool context persistence (per-thread, survives across webhook invocations) ─ + +export interface ToolContextEntry { + toolName: string; + args: Record; + result: unknown; + timestamp: number; +} + +function toolContextKey(threadId: string): string { + return `calcom:tool_context:${threadId}`; +} + +export async function getToolContext(threadId: string): Promise { + const client = getRedisClient(); + const raw = await client.get(toolContextKey(threadId)); + if (!raw) return []; + try { + return JSON.parse(decryptData(raw)) as ToolContextEntry[]; + } catch { + return []; + } +} + +export async function setToolContext( + threadId: string, + entries: ToolContextEntry[] +): Promise { + const client = getRedisClient(); + await client.set(toolContextKey(threadId), encryptData(JSON.stringify(entries)), { + EX: TOOL_CONTEXT_TTL_SECONDS, + }); +} + // ─── Workspace notification config (unchanged) ────────────────────────────── export interface WorkspaceNotificationConfig {