From 5e449670ea833577af6b1134321ed27a84e732ae Mon Sep 17 00:00:00 2001 From: DexterStorey <36115192+DexterStorey@users.noreply.github.com> Date: Fri, 22 Sep 2023 19:23:19 -0400 Subject: [PATCH] feat: Cal AI V1.1.0 (#11446) Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com> Co-authored-by: tedspare --- apps/ai/.env.example | 3 + apps/ai/package.json | 2 +- apps/ai/src/app/api/agent/route.ts | 6 +- apps/ai/src/app/api/receive/route.ts | 9 ++- apps/ai/src/env.mjs | 2 + ...BookingIfAvailable.ts => createBooking.ts} | 39 +++++---- apps/ai/src/tools/getAvailability.ts | 35 ++++---- apps/ai/src/tools/getBookings.ts | 2 +- apps/ai/src/tools/getEventTypes.ts | 18 +++-- apps/ai/src/tools/sendBookingLink.ts | 81 +++++++++++++++++++ apps/ai/src/types/user.ts | 9 +++ apps/ai/src/utils/agent.ts | 75 ++++++++++++----- apps/ai/src/utils/context.ts | 1 + apps/ai/src/utils/extractUsers.ts | 79 ++++++++++++++++++ apps/ai/src/utils/sendEmail.ts | 5 +- yarn.lock | 2 +- 16 files changed, 299 insertions(+), 69 deletions(-) rename apps/ai/src/tools/{createBookingIfAvailable.ts => createBooking.ts} (76%) create mode 100644 apps/ai/src/tools/sendBookingLink.ts create mode 100644 apps/ai/src/utils/context.ts create mode 100644 apps/ai/src/utils/extractUsers.ts diff --git a/apps/ai/.env.example b/apps/ai/.env.example index cdec550390..2aad722973 100644 --- a/apps/ai/.env.example +++ b/apps/ai/.env.example @@ -1,4 +1,7 @@ BACKEND_URL=http://localhost:3002/api +# BACKEND_URL=https://api.cal.com/v1 +FRONTEND_URL=http://localhost:3000 +# FRONTEND_URL=https://cal.com APP_ID=cal-ai APP_URL=http://localhost:3000/apps/cal-ai diff --git a/apps/ai/package.json b/apps/ai/package.json index 5c338be474..a0dc4943ec 100644 --- a/apps/ai/package.json +++ b/apps/ai/package.json @@ -1,6 +1,6 @@ { "name": "@calcom/ai", - "version": "1.0.1", + "version": "1.1.0", "private": true, "author": "Cal.com Inc.", "dependencies": { diff --git a/apps/ai/src/app/api/agent/route.ts b/apps/ai/src/app/api/agent/route.ts index ca6a1924e8..b707c9d56d 100644 --- a/apps/ai/src/app/api/agent/route.ts +++ b/apps/ai/src/app/api/agent/route.ts @@ -18,21 +18,21 @@ export const POST = async (request: NextRequest) => { const json = await request.json(); - const { apiKey, userId, message, subject, user, replyTo } = json; + const { apiKey, userId, message, subject, user, users, replyTo: agentEmail } = json; if ((!message && !subject) || !user) { return new NextResponse("Missing fields", { status: 400 }); } try { - const response = await agent(`${subject}\n\n${message}`, user, apiKey, userId); + const response = await agent(`${subject}\n\n${message}`, { ...user }, users, apiKey, userId, agentEmail); // Send response to user await sendEmail({ subject: `Re: ${subject}`, text: response.replace(/(?:\r\n|\r|\n)/g, "\n"), to: user.email, - from: replyTo, + from: agentEmail, }); return new NextResponse("ok"); diff --git a/apps/ai/src/app/api/receive/route.ts b/apps/ai/src/app/api/receive/route.ts index b66b639bb5..262a72e5e5 100644 --- a/apps/ai/src/app/api/receive/route.ts +++ b/apps/ai/src/app/api/receive/route.ts @@ -8,6 +8,7 @@ import prisma from "@calcom/prisma"; import { env } from "../../../env.mjs"; import { fetchAvailability } from "../../../tools/getAvailability"; import { fetchEventTypes } from "../../../tools/getEventTypes"; +import { extractUsers } from "../../../utils/extractUsers"; import getHostFromHeaders from "../../../utils/host"; import now from "../../../utils/now"; import sendEmail from "../../../utils/sendEmail"; @@ -45,6 +46,7 @@ export const POST = async (request: NextRequest) => { select: { email: true, id: true, + username: true, timeZone: true, credentials: { select: { @@ -53,7 +55,7 @@ export const POST = async (request: NextRequest) => { }, }, }, - where: { email: envelope.from }, + where: { email: envelope.from, credentials: { some: { appId: env.APP_ID } } }, }); // User is not a cal.com user or is using an unverified email. @@ -89,7 +91,7 @@ export const POST = async (request: NextRequest) => { const { apiKey } = credential as { apiKey: string }; // Pre-fetch data relevant to most bookings. - const [eventTypes, availability] = await Promise.all([ + const [eventTypes, availability, users] = await Promise.all([ fetchEventTypes({ apiKey, }), @@ -99,6 +101,7 @@ export const POST = async (request: NextRequest) => { dateFrom: now(user.timeZone), dateTo: now(user.timeZone), }), + extractUsers(`${parsed.text} ${parsed.subject}`), ]); if ("error" in availability) { @@ -138,9 +141,11 @@ export const POST = async (request: NextRequest) => { user: { email: user.email, eventTypes, + username: user.username, timeZone: user.timeZone, workingHours, }, + users, }), headers: { "Content-Type": "application/json", diff --git a/apps/ai/src/env.mjs b/apps/ai/src/env.mjs index 6081733698..567bb4c19d 100644 --- a/apps/ai/src/env.mjs +++ b/apps/ai/src/env.mjs @@ -17,6 +17,7 @@ export const env = createEnv({ */ runtimeEnv: { BACKEND_URL: process.env.BACKEND_URL, + FRONTEND_URL: process.env.FRONTEND_URL, APP_ID: process.env.APP_ID, APP_URL: process.env.APP_URL, PARSE_KEY: process.env.PARSE_KEY, @@ -32,6 +33,7 @@ export const env = createEnv({ */ server: { BACKEND_URL: z.string().url(), + FRONTEND_URL: z.string().url(), APP_ID: z.string().min(1), APP_URL: z.string().url(), PARSE_KEY: z.string().min(1), diff --git a/apps/ai/src/tools/createBookingIfAvailable.ts b/apps/ai/src/tools/createBooking.ts similarity index 76% rename from apps/ai/src/tools/createBookingIfAvailable.ts rename to apps/ai/src/tools/createBooking.ts index 976758b0ea..ec9d34c428 100644 --- a/apps/ai/src/tools/createBookingIfAvailable.ts +++ b/apps/ai/src/tools/createBooking.ts @@ -1,6 +1,8 @@ import { DynamicStructuredTool } from "langchain/tools"; import { z } from "zod"; +import type { UserList } from "~/src/types/user"; + import { env } from "../env.mjs"; /** @@ -9,21 +11,23 @@ import { env } from "../env.mjs"; const createBooking = async ({ apiKey, userId, + users, eventTypeId, start, end, timeZone, language, - responses, + invite, }: { apiKey: string; userId: number; + users: UserList; eventTypeId: number; start: string; end: string; timeZone: string; language: string; - responses: { name?: string; email?: string; location?: string }; + invite: number; title?: string; status?: string; }): Promise => { @@ -36,6 +40,18 @@ const createBooking = async ({ const url = `${env.BACKEND_URL}/bookings?${urlParams.toString()}`; + const user = users.find((u) => u.id === invite); + + if (!user) { + return { error: `User with id ${invite} not found to invite` }; + } + + const responses = { + id: invite, + name: user.username, + email: user.email, + }; + const response = await fetch(url, { body: JSON.stringify({ end, @@ -66,19 +82,19 @@ const createBooking = async ({ return "Booking created"; }; -const createBookingTool = (apiKey: string, userId: number) => { +const createBookingTool = (apiKey: string, userId: number, users: UserList) => { return new DynamicStructuredTool({ - description: - "Tries to create a booking. If the user is unavailable, it will return availability that day, allowing you to avoid the getAvailability step in many cases.", - func: async ({ eventTypeId, start, end, timeZone, language, responses, title, status }) => { + description: "Creates a booking on the primary user's calendar.", + func: async ({ eventTypeId, start, end, timeZone, language, invite, title, status }) => { return JSON.stringify( await createBooking({ apiKey, userId, + users, end, eventTypeId, language, - responses, + invite, start, status, timeZone, @@ -86,19 +102,14 @@ const createBookingTool = (apiKey: string, userId: number) => { }) ); }, - name: "createBookingIfAvailable", + name: "createBooking", schema: z.object({ end: z .string() .describe("This should correspond to the event type's length, unless otherwise specified."), eventTypeId: z.number(), language: z.string(), - responses: z - .object({ - email: z.string().optional(), - name: z.string().optional(), - }) - .describe("External invited user. Not the user making the request."), + invite: z.number().describe("External user id to invite."), start: z.string(), status: z.string().optional().describe("ACCEPTED, PENDING, CANCELLED or REJECTED"), timeZone: z.string(), diff --git a/apps/ai/src/tools/getAvailability.ts b/apps/ai/src/tools/getAvailability.ts index bd0ebf8788..028f5955ac 100644 --- a/apps/ai/src/tools/getAvailability.ts +++ b/apps/ai/src/tools/getAvailability.ts @@ -12,13 +12,11 @@ export const fetchAvailability = async ({ userId, dateFrom, dateTo, - eventTypeId, }: { apiKey: string; userId: number; dateFrom: string; dateTo: string; - eventTypeId?: number; }): Promise | { error: string }> => { const params: { [k: string]: string } = { apiKey, @@ -27,8 +25,6 @@ export const fetchAvailability = async ({ dateTo, }; - if (eventTypeId) params["eventTypeId"] = eventTypeId.toString(); - const urlParams = new URLSearchParams(params); const url = `${env.BACKEND_URL}/availability?${urlParams.toString()}`; @@ -51,30 +47,29 @@ export const fetchAvailability = async ({ }; }; -const getAvailabilityTool = (apiKey: string, userId: number) => { +const getAvailabilityTool = (apiKey: string) => { return new DynamicStructuredTool({ - description: "Get availability within range.", - func: async ({ dateFrom, dateTo, eventTypeId }) => { + description: "Get availability of users within range.", + func: async ({ userIds, dateFrom, dateTo }) => { return JSON.stringify( - await fetchAvailability({ - apiKey, - userId, - dateFrom, - dateTo, - eventTypeId, - }) + await Promise.all( + userIds.map( + async (userId) => + await fetchAvailability({ + userId: userId, + apiKey, + dateFrom, + dateTo, + }) + ) + ) ); }, name: "getAvailability", schema: z.object({ + userIds: z.array(z.number()).describe("The users to fetch availability for."), dateFrom: z.string(), dateTo: z.string(), - eventTypeId: z - .number() - .optional() - .describe( - "The ID of the event type to filter availability for if you've called getEventTypes, otherwise do not include." - ), }), }); }; diff --git a/apps/ai/src/tools/getBookings.ts b/apps/ai/src/tools/getBookings.ts index 7296c6fb45..2ccb563daf 100644 --- a/apps/ai/src/tools/getBookings.ts +++ b/apps/ai/src/tools/getBookings.ts @@ -60,7 +60,7 @@ const fetchBookings = async ({ const getBookingsTool = (apiKey: string, userId: number) => { return new DynamicStructuredTool({ - description: "Get bookings for a user between two dates.", + description: "Get bookings for the primary user between two dates.", func: async ({ from, to }) => { return JSON.stringify(await fetchBookings({ apiKey, userId, from, to })); }, diff --git a/apps/ai/src/tools/getEventTypes.ts b/apps/ai/src/tools/getEventTypes.ts index 53d7b5e18d..eeecbc1f9b 100644 --- a/apps/ai/src/tools/getEventTypes.ts +++ b/apps/ai/src/tools/getEventTypes.ts @@ -7,11 +7,15 @@ import type { EventType } from "../types/eventType"; /** * Fetches event types by user ID. */ -export const fetchEventTypes = async ({ apiKey }: { apiKey: string }) => { - const params = { +export const fetchEventTypes = async ({ apiKey, userId }: { apiKey: string; userId?: number }) => { + const params: Record = { apiKey, }; + if (userId) { + params["userId"] = userId.toString(); + } + const urlParams = new URLSearchParams(params); const url = `${env.BACKEND_URL}/event-types?${urlParams.toString()}`; @@ -28,6 +32,7 @@ export const fetchEventTypes = async ({ apiKey }: { apiKey: string }) => { return data.event_types.map((eventType: EventType) => ({ id: eventType.id, + slug: eventType.slug, length: eventType.length, title: eventType.title, })); @@ -35,16 +40,19 @@ export const fetchEventTypes = async ({ apiKey }: { apiKey: string }) => { const getEventTypesTool = (apiKey: string) => { return new DynamicStructuredTool({ - description: "Get the user's event type IDs. Usually necessary to book a meeting.", - func: async () => { + description: "Get a user's event type IDs. Usually necessary to book a meeting.", + func: async ({ userId }) => { return JSON.stringify( await fetchEventTypes({ apiKey, + userId, }) ); }, name: "getEventTypes", - schema: z.object({}), + schema: z.object({ + userId: z.number().optional().describe("The user ID. Defaults to the primary user's ID."), + }), }); }; diff --git a/apps/ai/src/tools/sendBookingLink.ts b/apps/ai/src/tools/sendBookingLink.ts new file mode 100644 index 0000000000..e23df6e52d --- /dev/null +++ b/apps/ai/src/tools/sendBookingLink.ts @@ -0,0 +1,81 @@ +import { DynamicStructuredTool } from "langchain/tools"; +import { z } from "zod"; + +import { env } from "~/src/env.mjs"; +import type { User, UserList } from "~/src/types/user"; +import sendEmail from "~/src/utils/sendEmail"; + +export const sendBookingLink = async ({ + user, + agentEmail, + subject, + to, + message, + eventTypeSlug, + date, +}: { + apiKey: string; + user: User; + users: UserList; + agentEmail: string; + subject: string; + to: string[]; + message: string; + eventTypeSlug: string; + date: string; +}) => { + const url = `${env.FRONTEND_URL}/${user.username}/${eventTypeSlug}?date=${date}`; + + await sendEmail({ + subject, + to, + cc: user.email, + from: agentEmail, + text: message.split("[[[Booking Link]]]").join(url), + html: message + .split("\n") + .join("
") + .split("[[[Booking Link]]]") + .join(`Booking Link`), + }); + + return "Booking link sent"; +}; + +const sendBookingLinkTool = (apiKey: string, user: User, users: UserList, agentEmail: string) => { + return new DynamicStructuredTool({ + description: "Send a booking link via email. Useful for scheduling with non cal users.", + func: async ({ message, subject, to, eventTypeSlug, date }) => { + return JSON.stringify( + await sendBookingLink({ + apiKey, + user, + users, + agentEmail, + subject, + to, + message, + eventTypeSlug, + date, + }) + ); + }, + name: "sendBookingLink", + + schema: z.object({ + message: z + .string() + .describe( + "Make sure to nicely format the message and introduce yourself as the primary user's booking assistant. Make sure to include a spot for the link using: [[[Booking Link]]]" + ), + subject: z.string(), + to: z + .array(z.string()) + .describe("array of emails to send the booking link to. Primary user is automatically CC'd"), + eventTypeSlug: z.string().describe("the slug of the event type to book"), + date: z.string().describe("the date (yyyy-mm-dd) to suggest for the booking"), + }), + }); +}; + +export default sendBookingLinkTool; diff --git a/apps/ai/src/types/user.ts b/apps/ai/src/types/user.ts index 8bb7dcdeee..e39b1b234b 100644 --- a/apps/ai/src/types/user.ts +++ b/apps/ai/src/types/user.ts @@ -2,8 +2,17 @@ import type { EventType } from "./eventType"; import type { WorkingHours } from "./workingHours"; export type User = { + id: number; email: string; + username: string; timeZone: string; eventTypes: EventType[]; workingHours: WorkingHours[]; }; + +export type UserList = { + id?: number; + email?: string; + username?: string; + type: "fromUsername" | "fromEmail"; +}[]; diff --git a/apps/ai/src/utils/agent.ts b/apps/ai/src/utils/agent.ts index c6ec0dd0ba..2164917d6f 100644 --- a/apps/ai/src/utils/agent.ts +++ b/apps/ai/src/utils/agent.ts @@ -2,13 +2,14 @@ import { initializeAgentExecutorWithOptions } from "langchain/agents"; import { ChatOpenAI } from "langchain/chat_models/openai"; import { env } from "../env.mjs"; -import createBookingIfAvailable from "../tools/createBookingIfAvailable"; +import createBookingIfAvailable from "../tools/createBooking"; import deleteBooking from "../tools/deleteBooking"; import getAvailability from "../tools/getAvailability"; import getBookings from "../tools/getBookings"; +import sendBookingLink from "../tools/sendBookingLink"; import updateBooking from "../tools/updateBooking"; import type { EventType } from "../types/eventType"; -import type { User } from "../types/user"; +import type { User, UserList } from "../types/user"; import type { WorkingHours } from "../types/workingHours"; import now from "./now"; @@ -19,13 +20,22 @@ const gptModel = "gpt-4"; * Uses a toolchain to book meetings, list available slots, etc. * Uses OpenAI functions to better enforce JSON-parsable output from the LLM. */ -const agent = async (input: string, user: User, apiKey: string, userId: number) => { +const agent = async ( + input: string, + user: User, + users: UserList, + apiKey: string, + userId: number, + agentEmail: string +) => { const tools = [ - createBookingIfAvailable(apiKey, userId), - getAvailability(apiKey, userId), + // getEventTypes(apiKey), + getAvailability(apiKey), getBookings(apiKey, userId), + createBookingIfAvailable(apiKey, userId, users), updateBooking(apiKey, userId), deleteBooking(apiKey), + sendBookingLink(apiKey, user, users, agentEmail), ]; const model = new ChatOpenAI({ @@ -40,23 +50,46 @@ const agent = async (input: string, user: User, apiKey: string, userId: number) const executor = await initializeAgentExecutorWithOptions(tools, model, { agentArgs: { prefix: `You are Cal AI - a bleeding edge scheduling assistant that interfaces via email. - Make sure your final answers are definitive, complete and well formatted. - Sometimes, tools return errors. In this case, try to handle the error intelligently or ask the user for more information. - Tools will always handle times in UTC, but times sent to the user should be formatted per that user's timezone. +Make sure your final answers are definitive, complete and well formatted. +Sometimes, tools return errors. In this case, try to handle the error intelligently or ask the user for more information. +Tools will always handle times in UTC, but times sent to users should be formatted per that user's timezone. - The current time in the user's timezone is: ${now(user.timeZone)} - The user's time zone is: ${user.timeZone} - The user's event types are: ${user.eventTypes - .map((e: EventType) => `ID: ${e.id}, Title: ${e.title}, Length: ${e.length}`) - .join("\n")} - The user's working hours are: ${user.workingHours - .map( - (w: WorkingHours) => - `Days: ${w.days.join(", ")}, Start Time (minutes in UTC): ${ - w.startTime - }, End Time (minutes in UTC): ${w.endTime}` - ) - .join("\n")} +The primary user's id is: ${userId} +The primary user's username is: ${user.username} +The current time in the primary user's timezone is: ${now(user.timeZone)} +The primary user's time zone is: ${user.timeZone} +The primary user's event types are: ${user.eventTypes + .map((e: EventType) => `ID: ${e.id}, Slug: ${e.slug}, Title: ${e.title}, Length: ${e.length};`) + .join("\n")} +The primary user's working hours are: ${user.workingHours + .map( + (w: WorkingHours) => + `Days: ${w.days.join(", ")}, Start Time (minutes in UTC): ${ + w.startTime + }, End Time (minutes in UTC): ${w.endTime};` + ) + .join("\n")} +${ + users.length + ? `The email references the following @usernames and emails: ${users + .map( + (u) => + (u.id ? `, id: ${u.id}` : "id: (non user)") + + (u.username + ? u.type === "fromUsername" + ? `, username: @${u.username}` + : ", username: REDACTED" + : ", (no username)") + + (u.email + ? u.type === "fromEmail" + ? `, email: ${u.email}` + : ", email: REDACTED" + : ", (no email)") + + ";" + ) + .join("\n")}` + : "" +} `, }, agentType: "openai-functions", diff --git a/apps/ai/src/utils/context.ts b/apps/ai/src/utils/context.ts new file mode 100644 index 0000000000..26864e73d6 --- /dev/null +++ b/apps/ai/src/utils/context.ts @@ -0,0 +1 @@ +export const context = { apiKey: "", userId: "" }; diff --git a/apps/ai/src/utils/extractUsers.ts b/apps/ai/src/utils/extractUsers.ts new file mode 100644 index 0000000000..b7c1345d59 --- /dev/null +++ b/apps/ai/src/utils/extractUsers.ts @@ -0,0 +1,79 @@ +/* + * Extracts usernames (@Example) and emails (hi@example.com) from a string + */ +import type { UserList } from "../types/user"; + +export const extractUsers = async (text: string) => { + const usernames = text.match(/(? username.slice(1)); + const emails = text.match(/[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+/g); + + const dbUsersFromUsernames = usernames + ? await prisma.user.findMany({ + select: { + id: true, + username: true, + email: true, + }, + where: { + username: { + in: usernames, + }, + }, + }) + : []; + + const usersFromUsernames = usernames + ? usernames.map((username) => { + const user = dbUsersFromUsernames.find((u) => u.username === username); + return user + ? { + username, + id: user.id, + email: user.email, + type: "fromUsername", + } + : { + username, + id: null, + email: null, + type: "fromUsername", + }; + }) + : []; + + const dbUsersFromEmails = emails + ? await prisma.user.findMany({ + select: { + id: true, + email: true, + username: true, + }, + where: { + email: { + in: emails, + }, + }, + }) + : []; + + const usersFromEmails = emails + ? emails.map((email) => { + const user = dbUsersFromEmails.find((u) => u.email === email); + return user + ? { + email, + id: user.id, + username: user.username, + type: "fromEmail", + } + : { + email, + id: null, + username: null, + type: "fromEmail", + }; + }) + : []; + + return [...usersFromUsernames, ...usersFromEmails] as UserList; +}; diff --git a/apps/ai/src/utils/sendEmail.ts b/apps/ai/src/utils/sendEmail.ts index 4939fb4a4a..799428091e 100644 --- a/apps/ai/src/utils/sendEmail.ts +++ b/apps/ai/src/utils/sendEmail.ts @@ -8,12 +8,14 @@ const sendgridAPIKey = process.env.SENDGRID_API_KEY as string; const send = async ({ subject, to, + cc, from, text, html, }: { subject: string; - to: string; + to: string | string[]; + cc?: string | string[]; from: string; text: string; html?: string; @@ -22,6 +24,7 @@ const send = async ({ const msg = { to, + cc, from: { email: from, name: "Cal AI", diff --git a/yarn.lock b/yarn.lock index 729fe3719c..4e58ba524d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -39631,4 +39631,4 @@ __metadata: resolution: "zwitch@npm:2.0.4" checksum: f22ec5fc2d5f02c423c93d35cdfa83573a3a3bd98c66b927c368ea4d0e7252a500df2a90a6b45522be536a96a73404393c958e945fdba95e6832c200791702b6 languageName: node - linkType: hard + linkType: hard \ No newline at end of file