diff --git a/apps/ai/.env.example b/apps/ai/.env.example new file mode 100644 index 0000000000..e6effa0a1e --- /dev/null +++ b/apps/ai/.env.example @@ -0,0 +1,21 @@ +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 + +# This is for the onboard route. Which domain should we send emails from? +SENDER_DOMAIN=cal.ai + +# Used to verify requests from sendgrid. You can generate a new one with: `openssl rand -hex 32` +PARSE_KEY= + +OPENAI_API_KEY= + +# Optionally trace completions at https://smith.langchain.com +# LANGCHAIN_TRACING_V2=true +# LANGCHAIN_ENDPOINT= +# LANGCHAIN_API_KEY= +# LANGCHAIN_PROJECT= diff --git a/apps/ai/README.md b/apps/ai/README.md new file mode 100644 index 0000000000..306bf07906 --- /dev/null +++ b/apps/ai/README.md @@ -0,0 +1,68 @@ +# Cal.ai + +Welcome to [Cal.ai](https://cal.ai)! + +This app lets you chat with your calendar via email: + +- Turn informal emails into bookings eg. forward "wanna meet tmrw at 2pm?" +- List and rearrange your bookings eg. "clear my afternoon" +- Answer basic questions about your busiest times eg. "how does my Tuesday look?" + +The core logic is contained in [agent/route.ts](/apps/ai/src/app/api/agent/route.ts). Here, a [LangChain Agent Executor](https://docs.langchain.com/docs/components/agents/agent-executor) is tasked with following your instructions. Given your last-known timezone, working hours, and busy times, it attempts to CRUD your bookings. + +_The AI agent can only choose from a set of tools, without ever seeing your API key._ + +Emails are cleaned and routed in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts) using [MailParser](https://nodemailer.com/extras/mailparser/). + +Incoming emails are routed by email address. Addresses are verified by [DKIM record](https://support.google.com/a/answer/174124?hl=en), making them hard to spoof. + +## Recognition + +Cal.ai - World's first open source AI scheduling assistant | Product Hunt Cal.ai - World's first open source AI scheduling assistant | Product Hunt + +## Getting Started + +### Development + +If you haven't yet, please run the [root setup](/README.md) steps. + +Before running the app, please see [env.mjs](./src/env.mjs) for all required environment variables. Run `cp .env.example .env` in this folder to get started. You'll need: + +- An [OpenAI API key](https://platform.openai.com/account/api-keys) with access to GPT-4 +- A [SendGrid API key](https://app.sendgrid.com/settings/api_keys) +- A default sender email (for example, `me@dev.example.com`) +- The Cal.ai app's ID and URL (see [add.ts](/packages/app-store/cal-ai/api/index.ts)) +- A unique value for `PARSE_KEY` with `openssl rand -hex 32` + +To stand up the API and AI apps simultaneously, simply run `yarn dev:ai`. + +### Agent Architecture + +The scheduling agent in [agent/route.ts](/apps/ai/src/app/api/agent/route.ts) calls an LLM (in this case, GPT-4) in a loop to accomplish a multi-step task. We use an [OpenAI Functions agent](https://js.langchain.com/docs/modules/agents/agent_types/openai_functions_agent), which is fine-tuned to output text suited for passing to tools. + +Tools (eg. [`createBooking`](/apps/ai/src/tools/createBooking.ts)) are simply JavaScript methods wrapped by Zod schemas, telling the agent what format to output. + +Here is the full architecture: + +![Cal.ai architecture](/apps/ai/src/public/architecture.png) + +### Email Router + +To expose the AI app, you can use either [Tunnelmole](https://github.com/robbie-cahill/tunnelmole-client), an open source tunnelling tool; or [nGrok](https://ngrok.com/), a popular closed source tunnelling tool. + +For Tunnelmole, run `tmole 3005` (or the AI app's port number) in a new terminal. Please replace `3005` with the port number if it is different. In the output, you'll see two URLs, one http and a https (we recommend using the https url for privacy and security). To install Tunnelmole, use `curl -O https://install.tunnelmole.com/8dPBw/install && sudo bash install`. (On Windows, download [tmole.exe](https://tunnelmole.com/downloads/tmole.exe)) + +For nGrok, run `ngrok http 3005` (or the AI app's port number) in a new terminal. You may need to install nGrok first. + +To forward incoming emails to the serverless function at `/agent`, we use [SendGrid's Inbound Parse](https://docs.sendgrid.com/for-developers/parsing-email/setting-up-the-inbound-parse-webhook). + +1. Ensure you have a [SendGrid account](https://signup.sendgrid.com/) +2. Ensure you have an authenticated domain. Go to Settings > Sender Authentication > Authenticate. For DNS host, select `I'm not sure`. Click Next and add your domain, eg. `example.com`. Choose Manual Setup. You'll be given three CNAME records to add to your DNS settings, eg. in [Vercel Domains](https://vercel.com/dashboard/domains). After adding those records, click Verify. To troubleshoot, see the [full instructions](https://docs.sendgrid.com/ui/account-and-settings/how-to-set-up-domain-authentication). +3. Authorize your domain for email with MX records: one with name `[your domain].com` and value `mx.sendgrid.net.`, and another with name `bounces.[your domain].com` and value `feedback-smtp.us-east-1.amazonses.com`. Set the priority to `10` if prompted. +4. Go to Settings > [Inbound Parse](https://app.sendgrid.com/settings/parse) > Add Host & URL. Choose your authenticated domain. +5. In the Destination URL field, use the Tunnelmole or ngrok URL from above along with the path, `/api/receive`, and one param, `parseKey`, which lives in [this app's .env](/apps/ai/.env.example) under `PARSE_KEY`. The full URL should look like `https://abc.tunnelmole.net/api/receive?parseKey=ABC-123` or `https://abc.ngrok.io/api/receive?parseKey=ABC-123`. +6. Activate "POST the raw, full MIME message". +7. Send an email to `[anyUsername]@example.com`. You should see a ping on the Tunnelmole or ngrok listener and server. +8. Adjust the logic in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts), save to hot-reload, and send another email to test the behaviour. + +Please feel free to improve any part of this architecture! diff --git a/apps/ai/next-env.d.ts b/apps/ai/next-env.d.ts new file mode 100644 index 0000000000..4f11a03dc6 --- /dev/null +++ b/apps/ai/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/ai/next.config.js b/apps/ai/next.config.js new file mode 100644 index 0000000000..747ded2324 --- /dev/null +++ b/apps/ai/next.config.js @@ -0,0 +1,24 @@ +const withBundleAnalyzer = require("@next/bundle-analyzer"); + +const plugins = []; +plugins.push(withBundleAnalyzer({ enabled: process.env.ANALYZE === "true" })); + +/** @type {import("next").NextConfig} */ +const nextConfig = { + async redirects() { + return [ + { + source: "/", + destination: "https://cal.com/ai", + permanent: true, + }, + ]; + }, + i18n: { + defaultLocale: "en", + locales: ["en"], + }, + reactStrictMode: true, +}; + +module.exports = () => plugins.reduce((acc, next) => next(acc), nextConfig); diff --git a/apps/ai/package.json b/apps/ai/package.json new file mode 100644 index 0000000000..3bd4127264 --- /dev/null +++ b/apps/ai/package.json @@ -0,0 +1,28 @@ +{ + "name": "@calcom/ai", + "version": "1.2.1", + "private": true, + "author": "Cal.com Inc.", + "dependencies": { + "@calcom/prisma": "*", + "@langchain/core": "^0.1.26", + "@langchain/openai": "^0.0.14", + "@t3-oss/env-nextjs": "^0.6.1", + "langchain": "^0.1.17", + "mailparser": "^3.6.5", + "next": "^13.5.4", + "supports-color": "8.1.1", + "zod": "^3.22.4" + }, + "devDependencies": { + "@types/mailparser": "^3.4.0" + }, + "scripts": { + "build": "next build", + "dev": "next dev -p 3005", + "format": "npx prettier . --write", + "lint": "eslint . --ext .ts,.js,.tsx,.jsx", + "lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix", + "start": "next start" + } +} diff --git a/apps/ai/src/app/api/agent/route.ts b/apps/ai/src/app/api/agent/route.ts new file mode 100644 index 0000000000..0ffbfa36b0 --- /dev/null +++ b/apps/ai/src/app/api/agent/route.ts @@ -0,0 +1,55 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +import agent from "../../../utils/agent"; +import sendEmail from "../../../utils/sendEmail"; +import { verifyParseKey } from "../../../utils/verifyParseKey"; + +// Allow agent loop to run for up to 5 minutes +export const maxDuration = 300; + +/** + * Launches a LangChain agent to process an incoming email, + * then sends the response to the user. + */ +export const POST = async (request: NextRequest) => { + const verified = verifyParseKey(request.url); + + if (!verified) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + const json = await request.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 }, 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: agentEmail, + }); + + return new NextResponse("ok"); + } catch (error) { + await sendEmail({ + subject: `Re: ${subject}`, + text: "Thanks for using Cal.ai! We're experiencing high demand and can't currently process your request. Please try again later.", + to: user.email, + from: agentEmail, + }); + + return new NextResponse( + (error as Error).message || "Something went wrong. Please try again or reach out for help.", + { status: 500 } + ); + } +}; diff --git a/apps/ai/src/app/api/onboard/route.ts b/apps/ai/src/app/api/onboard/route.ts new file mode 100644 index 0000000000..e4eaaef8cf --- /dev/null +++ b/apps/ai/src/app/api/onboard/route.ts @@ -0,0 +1,44 @@ +import type { NextRequest } from "next/server"; + +import prisma from "@calcom/prisma"; + +import { env } from "../../../env.mjs"; +import sendEmail from "../../../utils/sendEmail"; + +export const POST = async (request: NextRequest) => { + const { userId } = await request.json(); + + const user = await prisma.user.findUnique({ + select: { + email: true, + name: true, + username: true, + }, + where: { + id: userId, + }, + }); + + if (!user) { + return new Response("User not found", { status: 404 }); + } + + await sendEmail({ + subject: "Welcome to Cal AI", + to: user.email, + from: `${user.username}@${env.SENDER_DOMAIN}`, + text: `Hi ${ + user.name || `@${user.username}` + },\n\nI'm Cal AI, your personal booking assistant! I'll be here, 24/7 to help manage your busy schedule and find times to meet with the people you care about.\n\nHere are some things you can ask me:\n\n- "Book a meeting with @someone" (The @ symbol lets you tag Cal.com users)\n- "What meetings do I have today?" (I'll show you your schedule)\n- "Find a time for coffee with someone@gmail.com" (I'll intro and send them some good times)\n\nI'm still learning, so if you have any feedback, please tweet it to @calcom!\n\nRemember, you can always reach me here, at ${ + user.username + }@${ + env.SENDER_DOMAIN + }.\n\nLooking forward to working together (:\n\n- Cal AI, Your personal booking assistant`, + html: `Hi ${ + user.name || `@${user.username}` + },

I'm Cal AI, your personal booking assistant! I'll be here, 24/7 to help manage your busy schedule and find times to meet with the people you care about.

Here are some things you can ask me:

- "Book a meeting with @someone" (The @ symbol lets you tag Cal.com users)
- "What meetings do I have today?" (I'll show you your schedule)
- "Find a time for coffee with someone@gmail.com" (I'll intro and send them some good times)

I'm still learning, so if you have any feedback, please send it to @calcom on X!

Remember, you can always reach me here, at ${ + user.username + }@${env.SENDER_DOMAIN}.

Looking forward to working together (:

- Cal AI`, + }); + return new Response("OK", { status: 200 }); +}; diff --git a/apps/ai/src/app/api/receive/route.ts b/apps/ai/src/app/api/receive/route.ts new file mode 100644 index 0000000000..68bbc51168 --- /dev/null +++ b/apps/ai/src/app/api/receive/route.ts @@ -0,0 +1,186 @@ +import type { ParsedMail, Source } from "mailparser"; +import { simpleParser } from "mailparser"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; +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"; +import { verifyParseKey } from "../../../utils/verifyParseKey"; + +// Allow receive loop to run for up to 30 seconds +// Why so long? the rate determining API call (getAvailability, getEventTypes) can take up to 15 seconds at peak times so we give it a little extra time to complete. +export const maxDuration = 30; + +/** + * Verifies email signature and app authorization, + * then hands off to booking agent. + */ +export const POST = async (request: NextRequest) => { + const verified = verifyParseKey(request.url); + + if (!verified) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + const formData = await request.formData(); + const body = Object.fromEntries(formData); + const envelope = JSON.parse(body.envelope as string); + + const aiEmail = envelope.to[0]; + const subject = body.subject || ""; + + try { + await checkRateLimitAndThrowError({ + identifier: `ai:email:${envelope.from}`, + rateLimitingType: "ai", + }); + } catch (error) { + await sendEmail({ + subject: `Re: ${subject}`, + text: "Thanks for using Cal.ai! You've reached your daily limit. Please try again tomorrow.", + to: envelope.from, + from: aiEmail, + }); + + return new NextResponse("Exceeded rate limit", { status: 200 }); // Don't return 429 to avoid triggering retry logic in SendGrid + } + + // Parse email from mixed MIME type + const parsed: ParsedMail = await simpleParser(body.email as Source); + + if (!parsed.text && !parsed.subject) { + await sendEmail({ + subject: `Re: ${subject}`, + text: "Thanks for using Cal.ai! It looks like you forgot to include a message. Please try again.", + to: envelope.from, + from: aiEmail, + }); + return new NextResponse("Email missing text and subject", { status: 400 }); + } + + const user = await prisma.user.findUnique({ + select: { + email: true, + id: true, + username: true, + timeZone: true, + credentials: { + select: { + appId: true, + key: true, + }, + }, + }, + where: { email: envelope.from }, + }); + + // body.dkim looks like {@domain-com.22222222.gappssmtp.com : pass} + const signature = (body.dkim as string).includes(" : pass"); + + // User is not a cal.com user or is using an unverified email. + if (!signature || !user) { + await sendEmail({ + html: `Thanks for your interest in Cal.ai! To get started, Make sure you have a cal.com account with this email address and then install Cal.ai here: go.cal.com/ai.`, + subject: `Re: ${subject}`, + text: `Thanks for your interest in Cal.ai! To get started, Make sure you have a cal.com account with this email address. You can sign up for an account at: https://cal.com/signup`, + to: envelope.from, + from: aiEmail, + }); + + return new NextResponse("ok"); + } + + const credential = user.credentials.find((c) => c.appId === env.APP_ID)?.key; + + // User has not installed the app from the app store. Direct them to install it. + if (!(credential as { apiKey: string })?.apiKey) { + const url = env.APP_URL; + + await sendEmail({ + html: `Thanks for using Cal.ai! To get started, the app must be installed. Click this link to install it.`, + subject: `Re: ${subject}`, + text: `Thanks for using Cal.ai! To get started, the app must be installed. Click this link to install the Cal.ai app: ${url}`, + to: envelope.from, + from: aiEmail, + }); + + return new NextResponse("ok"); + } + + const { apiKey } = credential as { apiKey: string }; + + // Pre-fetch data relevant to most bookings. + const [eventTypes, availability, users] = await Promise.all([ + fetchEventTypes({ + apiKey, + }), + fetchAvailability({ + apiKey, + userId: user.id, + dateFrom: now(user.timeZone), + dateTo: now(user.timeZone), + }), + extractUsers(`${parsed.text} ${parsed.subject}`), + ]); + + if ("error" in availability) { + await sendEmail({ + subject: `Re: ${subject}`, + text: "Sorry, there was an error fetching your availability. Please try again.", + to: user.email, + from: aiEmail, + }); + console.error(availability.error); + return new NextResponse("Error fetching availability. Please try again.", { status: 400 }); + } + + if ("error" in eventTypes) { + await sendEmail({ + subject: `Re: ${subject}`, + text: "Sorry, there was an error fetching your event types. Please try again.", + to: user.email, + from: aiEmail, + }); + console.error(eventTypes.error); + return new NextResponse("Error fetching event types. Please try again.", { status: 400 }); + } + + const { workingHours } = availability; + + const appHost = getHostFromHeaders(request.headers); + + // Hand off to long-running agent endpoint to handle the email. (don't await) + fetch(`${appHost}/api/agent?parseKey=${env.PARSE_KEY}`, { + body: JSON.stringify({ + apiKey, + userId: user.id, + message: parsed.text || "", + subject: parsed.subject || "", + replyTo: aiEmail, + user: { + email: user.email, + eventTypes, + username: user.username, + timeZone: user.timeZone, + workingHours, + }, + users, + }), + headers: { + "Content-Type": "application/json", + }, + method: "POST", + }); + + await new Promise((r) => setTimeout(r, 1000)); + + return new NextResponse("ok"); +}; diff --git a/apps/ai/src/env.mjs b/apps/ai/src/env.mjs new file mode 100644 index 0000000000..2596a26643 --- /dev/null +++ b/apps/ai/src/env.mjs @@ -0,0 +1,47 @@ +import { createEnv } from "@t3-oss/env-nextjs"; +import { z } from "zod"; + +export const env = createEnv({ + /** + * Specify your client-side environment variables schema here. This way you can ensure the app + * isn't built with invalid env vars. To expose them to the client, prefix them with + * `NEXT_PUBLIC_`. + */ + client: { + // NEXT_PUBLIC_CLIENTVAR: z.string().min(1), + }, + + /** + * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g. + * middlewares) or client-side so we need to destruct manually. + */ + 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, + SENDER_DOMAIN: process.env.SENDER_DOMAIN, + PARSE_KEY: process.env.PARSE_KEY, + NODE_ENV: process.env.NODE_ENV, + OPENAI_API_KEY: process.env.OPENAI_API_KEY, + SENDGRID_API_KEY: process.env.SENDGRID_API_KEY, + DATABASE_URL: process.env.DATABASE_URL, + }, + + /** + * Specify your server-side environment variables schema here. This way you can ensure the app + * isn't built with invalid env vars. + */ + server: { + BACKEND_URL: z.string().url(), + FRONTEND_URL: z.string().url(), + APP_ID: z.string().min(1), + APP_URL: z.string().url(), + SENDER_DOMAIN: z.string().min(1), + PARSE_KEY: z.string().min(1), + NODE_ENV: z.enum(["development", "test", "production"]), + OPENAI_API_KEY: z.string().min(1), + SENDGRID_API_KEY: z.string().min(1), + DATABASE_URL: z.string().url(), + }, +}); diff --git a/apps/ai/src/public/architecture.png b/apps/ai/src/public/architecture.png new file mode 100644 index 0000000000..52eedfe6f5 Binary files /dev/null and b/apps/ai/src/public/architecture.png differ diff --git a/apps/ai/src/tools/createBooking.ts b/apps/ai/src/tools/createBooking.ts new file mode 100644 index 0000000000..40bb4d0fc1 --- /dev/null +++ b/apps/ai/src/tools/createBooking.ts @@ -0,0 +1,121 @@ +import { DynamicStructuredTool } from "@langchain/core/tools"; +import { z } from "zod"; + +import type { UserList } from "~/src/types/user"; + +import { env } from "../env.mjs"; + +/** + * Creates a booking for a user by event type, times, and timezone. + */ +const createBooking = async ({ + apiKey, + userId, + users, + eventTypeId, + start, + end, + timeZone, + language, + invite, +}: { + apiKey: string; + userId: number; + users: UserList; + eventTypeId: number; + start: string; + end: string; + timeZone: string; + language: string; + invite: number; + title?: string; + status?: string; +}): Promise => { + const params = { + apiKey, + userId: userId.toString(), + }; + + const urlParams = new URLSearchParams(params); + + 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.toString(), + name: user.username, + email: user.email, + }; + + const response = await fetch(url, { + body: JSON.stringify({ + end, + eventTypeId, + language, + metadata: {}, + responses, + start, + timeZone, + }), + headers: { + "Content-Type": "application/json", + }, + method: "POST", + }); + + // Let GPT handle this. This will happen when wrong event type id is used. + // if (response.status === 401) throw new Error("Unauthorized"); + + const data = await response.json(); + + if (response.status !== 200) { + return { + error: data.message, + }; + } + + return "Booking created"; +}; + +const createBookingTool = (apiKey: string, userId: number, users: UserList) => { + return new DynamicStructuredTool({ + 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, + invite, + start, + status, + timeZone, + title, + }) + ); + }, + 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(), + 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(), + title: z.string().optional(), + }), + }); +}; + +export default createBookingTool; diff --git a/apps/ai/src/tools/deleteBooking.ts b/apps/ai/src/tools/deleteBooking.ts new file mode 100644 index 0000000000..981cef8f58 --- /dev/null +++ b/apps/ai/src/tools/deleteBooking.ts @@ -0,0 +1,66 @@ +import { DynamicStructuredTool } from "@langchain/core/tools"; +import { z } from "zod"; + +import { env } from "../env.mjs"; + +/** + * Cancels a booking for a user by ID with reason. + */ +const cancelBooking = async ({ + apiKey, + id, + reason, +}: { + apiKey: string; + id: string; + reason: string; +}): Promise => { + const params = { + apiKey, + }; + + const urlParams = new URLSearchParams(params); + + const url = `${env.BACKEND_URL}/bookings/${id}/cancel?${urlParams.toString()}`; + + const response = await fetch(url, { + body: JSON.stringify({ reason }), + headers: { + "Content-Type": "application/json", + }, + method: "DELETE", + }); + + // Let GPT handle this. This will happen when wrong booking id is used. + // if (response.status === 401) throw new Error("Unauthorized"); + + const data = await response.json(); + + if (response.status !== 200) { + return { error: data.message }; + } + + return "Booking cancelled"; +}; + +const cancelBookingTool = (apiKey: string) => { + return new DynamicStructuredTool({ + description: "Cancel a booking", + func: async ({ id, reason }) => { + return JSON.stringify( + await cancelBooking({ + apiKey, + id, + reason, + }) + ); + }, + name: "cancelBooking", + schema: z.object({ + id: z.string(), + reason: z.string(), + }), + }); +}; + +export default cancelBookingTool; diff --git a/apps/ai/src/tools/getAvailability.ts b/apps/ai/src/tools/getAvailability.ts new file mode 100644 index 0000000000..26258e0c60 --- /dev/null +++ b/apps/ai/src/tools/getAvailability.ts @@ -0,0 +1,77 @@ +import { DynamicStructuredTool } from "@langchain/core/tools"; +import { z } from "zod"; + +import { env } from "../env.mjs"; +import type { Availability } from "../types/availability"; + +/** + * Fetches availability for a user by date range and event type. + */ +export const fetchAvailability = async ({ + apiKey, + userId, + dateFrom, + dateTo, +}: { + apiKey: string; + userId: number; + dateFrom: string; + dateTo: string; +}): Promise | { error: string }> => { + const params: { [k: string]: string } = { + apiKey, + userId: userId.toString(), + dateFrom, + dateTo, + }; + + const urlParams = new URLSearchParams(params); + + const url = `${env.BACKEND_URL}/availability?${urlParams.toString()}`; + + const response = await fetch(url); + + if (response.status === 401) throw new Error("Unauthorized"); + + const data = await response.json(); + + if (response.status !== 200) { + return { error: data.message }; + } + + return { + busy: data.busy, + dateRanges: data.dateRanges, + timeZone: data.timeZone, + workingHours: data.workingHours, + }; +}; + +const getAvailabilityTool = (apiKey: string) => { + return new DynamicStructuredTool({ + description: "Get availability of users within range.", + func: async ({ userIds, dateFrom, dateTo }) => { + return JSON.stringify( + 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(), + }), + }); +}; + +export default getAvailabilityTool; diff --git a/apps/ai/src/tools/getBookings.ts b/apps/ai/src/tools/getBookings.ts new file mode 100644 index 0000000000..6976f31ecf --- /dev/null +++ b/apps/ai/src/tools/getBookings.ts @@ -0,0 +1,75 @@ +import { DynamicStructuredTool } from "@langchain/core/tools"; +import { z } from "zod"; + +import { env } from "../env.mjs"; +import type { Booking } from "../types/booking"; +import { BOOKING_STATUS } from "../types/booking"; + +/** + * Fetches bookings for a user by date range. + */ +const fetchBookings = async ({ + apiKey, + userId, + from, + to, +}: { + apiKey: string; + userId: number; + from: string; + to: string; +}): Promise => { + const params = { + apiKey, + userId: userId.toString(), + dateFrom: from, + dateTo: to, + }; + + const urlParams = new URLSearchParams(params); + + const url = `${env.BACKEND_URL}/bookings?${urlParams.toString()}`; + + const response = await fetch(url); + + if (response.status === 401) throw new Error("Unauthorized"); + + const data = await response.json(); + + if (response.status !== 200) { + return { error: data.message }; + } + + const bookings: Booking[] = data.bookings + .filter((booking: Booking) => { + const notCancelled = booking.status !== BOOKING_STATUS.CANCELLED; + + return notCancelled; + }) + .map(({ endTime, eventTypeId, id, startTime, status, title }: Booking) => ({ + endTime, + eventTypeId, + id, + startTime, + status, + title, + })); + + return bookings; +}; + +const getBookingsTool = (apiKey: string, userId: number) => { + return new DynamicStructuredTool({ + description: "Get bookings for the primary user between two dates.", + func: async ({ from, to }) => { + return JSON.stringify(await fetchBookings({ apiKey, userId, from, to })); + }, + name: "getBookings", + schema: z.object({ + from: z.string().describe("ISO 8601 datetime string"), + to: z.string().describe("ISO 8601 datetime string"), + }), + }); +}; + +export default getBookingsTool; diff --git a/apps/ai/src/tools/getEventTypes.ts b/apps/ai/src/tools/getEventTypes.ts new file mode 100644 index 0000000000..c59df69c99 --- /dev/null +++ b/apps/ai/src/tools/getEventTypes.ts @@ -0,0 +1,59 @@ +import { DynamicStructuredTool } from "@langchain/core/tools"; +import { z } from "zod"; + +import { env } from "../env.mjs"; +import type { EventType } from "../types/eventType"; + +/** + * Fetches event types by user ID. + */ +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()}`; + + const response = await fetch(url); + + if (response.status === 401) throw new Error("Unauthorized"); + + const data = await response.json(); + + if (response.status !== 200) { + return { error: data.message }; + } + + return data.event_types.map((eventType: EventType) => ({ + id: eventType.id, + slug: eventType.slug, + length: eventType.length, + title: eventType.title, + })); +}; + +const getEventTypesTool = (apiKey: string) => { + return new DynamicStructuredTool({ + 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({ + userId: z.number().optional().describe("The user ID. Defaults to the primary user's ID."), + }), + }); +}; + +export default getEventTypesTool; diff --git a/apps/ai/src/tools/sendBookingEmail.ts b/apps/ai/src/tools/sendBookingEmail.ts new file mode 100644 index 0000000000..2133f61769 --- /dev/null +++ b/apps/ai/src/tools/sendBookingEmail.ts @@ -0,0 +1,124 @@ +import { DynamicStructuredTool } from "@langchain/core/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 sendBookingEmail = async ({ + user, + agentEmail, + subject, + to, + message, + eventTypeSlug, + slots, + date, +}: { + apiKey: string; + user: User; + users: UserList; + agentEmail: string; + subject: string; + to: string; + message: string; + eventTypeSlug: string; + slots?: { + time: string; + text: string; + }[]; + date: { + date: string; + text: string; + }; +}) => { + // const url = `${env.FRONTEND_URL}/${user.username}/${eventTypeSlug}?date=${date}`; + const timeUrls = slots?.map(({ time, text }) => { + return { + url: `${env.FRONTEND_URL}/${user.username}/${eventTypeSlug}?slot=${time}`, + text, + }; + }); + + const dateUrl = { + url: `${env.FRONTEND_URL}/${user.username}/${eventTypeSlug}?date=${date.date}`, + text: date.text, + }; + + await sendEmail({ + subject, + to, + cc: user.email, + from: agentEmail, + text: message + .split("[[[Slots]]]") + .join(timeUrls?.map(({ url, text }) => `${text}: ${url}`).join("\n")) + .split("[[[Link]]]") + .join(`${dateUrl.text}: ${dateUrl.url}`), + html: message + .split("\n") + .join("
") + .split("[[[Slots]]]") + .join(timeUrls?.map(({ url, text }) => `${text}`).join("
")) + .split("[[[Link]]]") + .join(`${dateUrl.text}`), + }); + + return "Booking link sent"; +}; + +const sendBookingEmailTool = (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. Be confident, suggesting a good date/time with a fallback to a link to select a date/time.", + func: async ({ message, subject, to, eventTypeSlug, slots, date }) => { + return JSON.stringify( + await sendBookingEmail({ + apiKey, + user, + users, + agentEmail, + subject, + to, + message, + eventTypeSlug, + slots, + date, + }) + ); + }, + name: "sendBookingEmail", + + schema: z.object({ + message: z + .string() + .describe( + "A polite and professional email with an intro and signature at the end. Specify you are the AI booking assistant of the primary user. Use [[[Slots]]] and a fallback [[[Link]]] to inject good times and 'see all times' into messages" + ), + subject: z.string(), + to: z + .string() + .describe("email address to send the booking link to. Primary user is automatically CC'd"), + eventTypeSlug: z.string().describe("the slug of the event type to book"), + slots: z + .array( + z.object({ + time: z.string().describe("YYYY-MM-DDTHH:mm in UTC"), + text: z.string().describe("minimum readable label. Ex. 4pm."), + }) + ) + .optional() + .describe("Time slots the external user can click"), + date: z + .object({ + date: z.string().describe("YYYY-MM-DD"), + text: z.string().describe('"See all times" or similar'), + }) + .describe( + "A booking link that allows the external user to select a date / time. Should be a fallback to time slots" + ), + }), + }); +}; + +export default sendBookingEmailTool; diff --git a/apps/ai/src/tools/updateBooking.ts b/apps/ai/src/tools/updateBooking.ts new file mode 100644 index 0000000000..eec462e897 --- /dev/null +++ b/apps/ai/src/tools/updateBooking.ts @@ -0,0 +1,85 @@ +import { DynamicStructuredTool } from "@langchain/core/tools"; +import { z } from "zod"; + +import { env } from "../env.mjs"; + +/** + * Edits a booking for a user by booking ID with new times, title, description, or status. + */ +const editBooking = async ({ + apiKey, + userId, + id, + startTime, // In the docs it says start, but it's startTime: https://cal.com/docs/enterprise-features/api/api-reference/bookings#edit-an-existing-booking. + endTime, // Same here: it says end but it's endTime. + title, + description, + status, +}: { + apiKey: string; + userId: number; + id: string; + startTime?: string; + endTime?: string; + title?: string; + description?: string; + status?: string; +}): Promise => { + const params = { + apiKey, + userId: userId.toString(), + }; + const urlParams = new URLSearchParams(params); + + const url = `${env.BACKEND_URL}/bookings/${id}?${urlParams.toString()}`; + + const response = await fetch(url, { + body: JSON.stringify({ description, endTime, startTime, status, title }), + headers: { + "Content-Type": "application/json", + }, + method: "PATCH", + }); + + // Let GPT handle this. This will happen when wrong booking id is used. + // if (response.status === 401) throw new Error("Unauthorized"); + + const data = await response.json(); + + if (response.status !== 200) { + return { error: data.message }; + } + + return "Booking edited"; +}; + +const editBookingTool = (apiKey: string, userId: number) => { + return new DynamicStructuredTool({ + description: "Edit a booking", + func: async ({ description, endTime, id, startTime, status, title }) => { + return JSON.stringify( + await editBooking({ + apiKey, + userId, + description, + endTime, + id, + startTime, + status, + title, + }) + ); + }, + name: "editBooking", + schema: z.object({ + description: z.string().optional(), + endTime: z.string().optional(), + id: z.string(), + startTime: z.string().optional(), + status: z.string().optional(), + title: z.string().optional(), + }), + }); +}; + +export default editBookingTool; diff --git a/apps/ai/src/types/availability.ts b/apps/ai/src/types/availability.ts new file mode 100644 index 0000000000..62ed3b5ed5 --- /dev/null +++ b/apps/ai/src/types/availability.ts @@ -0,0 +1,25 @@ +export type Availability = { + busy: { + start: string; + end: string; + title?: string; + }[]; + timeZone: string; + dateRanges: { + start: string; + end: string; + }[]; + workingHours: { + days: number[]; + startTime: number; + endTime: number; + userId: number; + }[]; + dateOverrides: { + date: string; + startTime: number; + endTime: number; + userId: number; + }; + currentSeats: number; +}; diff --git a/apps/ai/src/types/booking.ts b/apps/ai/src/types/booking.ts new file mode 100644 index 0000000000..91eaf4e322 --- /dev/null +++ b/apps/ai/src/types/booking.ts @@ -0,0 +1,23 @@ +export enum BOOKING_STATUS { + ACCEPTED = "ACCEPTED", + PENDING = "PENDING", + CANCELLED = "CANCELLED", + REJECTED = "REJECTED", +} + +export type Booking = { + id: number; + userId: number; + description: string | null; + eventTypeId: number; + uid: string; + title: string; + startTime: string; + endTime: string; + attendees: { email: string; name: string; timeZone: string; locale: string }[] | null; + user: { email: string; name: string; timeZone: string; locale: string }[] | null; + payment: { id: number; success: boolean; paymentOption: string }[]; + metadata: object | null; + status: BOOKING_STATUS; + responses: { email: string; name: string; location: string } | null; +}; diff --git a/apps/ai/src/types/eventType.ts b/apps/ai/src/types/eventType.ts new file mode 100644 index 0000000000..b9de4cc230 --- /dev/null +++ b/apps/ai/src/types/eventType.ts @@ -0,0 +1,13 @@ +export type EventType = { + id: number; + title: string; + length: number; + metadata: object; + slug: string; + hosts: { + userId: number; + isFixed: boolean; + }[]; + hidden: boolean; + // ... +}; diff --git a/apps/ai/src/types/user.ts b/apps/ai/src/types/user.ts new file mode 100644 index 0000000000..e39b1b234b --- /dev/null +++ b/apps/ai/src/types/user.ts @@ -0,0 +1,18 @@ +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/types/workingHours.ts b/apps/ai/src/types/workingHours.ts new file mode 100644 index 0000000000..e3a1905a45 --- /dev/null +++ b/apps/ai/src/types/workingHours.ts @@ -0,0 +1,5 @@ +export type WorkingHours = { + days: number[]; + startTime: number; + endTime: number; +}; diff --git a/apps/ai/src/utils/agent.ts b/apps/ai/src/utils/agent.ts new file mode 100644 index 0000000000..4ef72f10cc --- /dev/null +++ b/apps/ai/src/utils/agent.ts @@ -0,0 +1,122 @@ +import { ChatPromptTemplate } from "@langchain/core/prompts"; +import { ChatOpenAI } from "@langchain/openai"; +import { AgentExecutor, createOpenAIFunctionsAgent } from "langchain/agents"; + +import { env } from "../env.mjs"; +import createBookingIfAvailable from "../tools/createBooking"; +import deleteBooking from "../tools/deleteBooking"; +import getAvailability from "../tools/getAvailability"; +import getBookings from "../tools/getBookings"; +import sendBookingEmail from "../tools/sendBookingEmail"; +import updateBooking from "../tools/updateBooking"; +import type { EventType } from "../types/eventType"; +import type { User, UserList } from "../types/user"; +import type { WorkingHours } from "../types/workingHours"; +import now from "./now"; + +const gptModel = "gpt-4-0125-preview"; + +/** + * Core of the Cal.ai booking agent: a LangChain Agent Executor. + * 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, + users: UserList, + apiKey: string, + userId: number, + agentEmail: string +) => { + const tools = [ + // getEventTypes(apiKey), + getAvailability(apiKey), + getBookings(apiKey, userId), + createBookingIfAvailable(apiKey, userId, users), + updateBooking(apiKey, userId), + deleteBooking(apiKey), + sendBookingEmail(apiKey, user, users, agentEmail), + ]; + + const model = new ChatOpenAI({ + modelName: gptModel, + openAIApiKey: env.OPENAI_API_KEY, + temperature: 0, + }); + + /** + * Initialize the agent executor with arguments. + */ + const prompt = + ChatPromptTemplate.fromTemplate(`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 users should be formatted per that user's timezone. + In responses to users, always summarize necessary context and open the door to follow ups. For example "I have booked your chat with @username for 3pm on Wednesday, December 20th, 2023 EST. Please let me know if you need to reschedule." + If you can't find a referenced user, ask the user for their email or @username. Make sure to specify that usernames require the @username format. Users don't know other users' userIds. + + 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, { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + hour: "numeric", + minute: "numeric", + })} + 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")}` + : "" + }`); + const agent = await createOpenAIFunctionsAgent({ + llm: model, + prompt, + tools, + }); + + const executor = new AgentExecutor({ + agent, + tools, + returnIntermediateSteps: env.NODE_ENV === "development", + verbose: env.NODE_ENV === "development", + }); + + const result = await executor.invoke({ input }); + const { output } = result; + + return output; +}; + +export default agent; 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..10a6f9fe84 --- /dev/null +++ b/apps/ai/src/utils/extractUsers.ts @@ -0,0 +1,85 @@ +import prisma from "@calcom/prisma"; + +import type { UserList } from "../types/user"; + +/* + * Extracts usernames (@Example) and emails (hi@example.com) from a string + */ +export const extractUsers = async (text: string) => { + const usernames = text + .match(/(? username.slice(1).toLowerCase()); + const emails = text + .match(/[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+/g) + ?.map((email) => email.toLowerCase()); + + 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/host.ts b/apps/ai/src/utils/host.ts new file mode 100644 index 0000000000..f215d7e7d0 --- /dev/null +++ b/apps/ai/src/utils/host.ts @@ -0,0 +1,7 @@ +import type { NextRequest } from "next/server"; + +const getHostFromHeaders = (headers: NextRequest["headers"]): string => { + return `https://${headers.get("host")}`; +}; + +export default getHostFromHeaders; diff --git a/apps/ai/src/utils/now.ts b/apps/ai/src/utils/now.ts new file mode 100644 index 0000000000..98630d7bc4 --- /dev/null +++ b/apps/ai/src/utils/now.ts @@ -0,0 +1,6 @@ +export default function now(timeZone: string, options: Intl.DateTimeFormatOptions = {}) { + return new Date().toLocaleString("en-US", { + timeZone, + ...options, + }); +} diff --git a/apps/ai/src/utils/sendEmail.ts b/apps/ai/src/utils/sendEmail.ts new file mode 100644 index 0000000000..adbc9693e7 --- /dev/null +++ b/apps/ai/src/utils/sendEmail.ts @@ -0,0 +1,43 @@ +import mail from "@sendgrid/mail"; + +const sendgridAPIKey = process.env.SENDGRID_API_KEY as string; + +/** + * Simply send an email by address, subject, and body. + */ +const send = async ({ + subject, + to, + cc, + from, + text, + html, +}: { + subject: string; + to: string | string[]; + cc?: string | string[]; + from: string; + text: string; + html?: string; +}): Promise => { + mail.setApiKey(sendgridAPIKey); + + const msg = { + to, + cc, + from: { + email: from, + name: "Cal.ai", + }, + text, + html, + subject, + }; + + const res = await mail.send(msg); + const success = !!res; + + return success; +}; + +export default send; diff --git a/apps/ai/src/utils/verifyParseKey.ts b/apps/ai/src/utils/verifyParseKey.ts new file mode 100644 index 0000000000..5ac81ed78c --- /dev/null +++ b/apps/ai/src/utils/verifyParseKey.ts @@ -0,0 +1,13 @@ +import type { NextRequest } from "next/server"; + +import { env } from "../env.mjs"; + +/** + * Verifies that the request contains the correct parse key. + * env.PARSE_KEY must be configured as a query param in the sendgrid inbound parse settings. + */ +export const verifyParseKey = (url: NextRequest["url"]) => { + const verified = new URL(url).searchParams.get("parseKey") === env.PARSE_KEY; + + return verified; +}; diff --git a/apps/ai/tsconfig.json b/apps/ai/tsconfig.json new file mode 100644 index 0000000000..aae1b4e758 --- /dev/null +++ b/apps/ai/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "@calcom/tsconfig/nextjs.json", + "compilerOptions": { + "strict": true, + "jsx": "preserve", + "baseUrl": ".", + "paths": { + "~/*": ["*"] + }, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + "../../packages/types/window.d.ts" + ], + "exclude": ["node_modules"] +} diff --git a/packages/app-store/apps.browser.generated.tsx b/packages/app-store/apps.browser.generated.tsx index e623dbc919..4fea31051f 100644 --- a/packages/app-store/apps.browser.generated.tsx +++ b/packages/app-store/apps.browser.generated.tsx @@ -11,6 +11,7 @@ export const InstallAppButtonMap = { vital: dynamic(() => import("./vital/components/InstallAppButton")), }; export const AppSettingsComponentsMap = { + "cal-ai": dynamic(() => import("./cal-ai/components/AppSettingsInterface")), "general-app-settings": dynamic(() => import("./templates/general-app-settings/components/AppSettingsInterface") ), diff --git a/packages/app-store/apps.metadata.generated.ts b/packages/app-store/apps.metadata.generated.ts index 46f6dfc241..b50a5ae7d2 100644 --- a/packages/app-store/apps.metadata.generated.ts +++ b/packages/app-store/apps.metadata.generated.ts @@ -9,6 +9,7 @@ import around_config_json from "./around/config.json"; import autocheckin_config_json from "./autocheckin/config.json"; import baa_for_hipaa_config_json from "./baa-for-hipaa/config.json"; import basecamp3_config_json from "./basecamp3/config.json"; +import cal_ai_config_json from "./cal-ai/config.json"; import { metadata as caldavcalendar__metadata_ts } from "./caldavcalendar/_metadata"; import campfire_config_json from "./campfire/config.json"; import campsite_config_json from "./campsite/config.json"; @@ -100,6 +101,7 @@ export const appStoreMetadata = { autocheckin: autocheckin_config_json, "baa-for-hipaa": baa_for_hipaa_config_json, basecamp3: basecamp3_config_json, + "cal-ai": cal_ai_config_json, caldavcalendar: caldavcalendar__metadata_ts, campfire: campfire_config_json, campsite: campsite_config_json, diff --git a/packages/app-store/apps.server.generated.ts b/packages/app-store/apps.server.generated.ts index b4fb5e6c65..9ba075ee3c 100644 --- a/packages/app-store/apps.server.generated.ts +++ b/packages/app-store/apps.server.generated.ts @@ -10,6 +10,7 @@ export const apiHandlers = { autocheckin: import("./autocheckin/api"), "baa-for-hipaa": import("./baa-for-hipaa/api"), basecamp3: import("./basecamp3/api"), + "cal-ai": import("./cal-ai/api"), caldavcalendar: import("./caldavcalendar/api"), campfire: import("./campfire/api"), campsite: import("./campsite/api"), diff --git a/packages/app-store/cal-ai/DESCRIPTION.md b/packages/app-store/cal-ai/DESCRIPTION.md new file mode 100644 index 0000000000..6c045ee7c0 --- /dev/null +++ b/packages/app-store/cal-ai/DESCRIPTION.md @@ -0,0 +1,32 @@ +--- +items: + - 1.jpg + - 2.jpg + - 3.jpg + - 4.jpg + - 5.jpg +--- + +{DESCRIPTION} + +## Example questions: + +- "Book a meeting with @cal-user this week at a good time" +- "Find a time to meet with not-a-cal-user@gmail.com this week" +- "Move my meeting with [Someone's Name] to next week" +- "What does my week look like?" +- "When is my next meeting?" +- "What is my least busy day next week?" +- "Cancel the meeting with [Someone's Name]" +- "Reschedule the meeting with not-a-cal-user@gmail.com" +- "Is @cal-user busy on Friday at noon?" +- "Move my meeting at 2pm tomorrow to next week on Friday at 5pm" + +### Or, any of that in a thread: +- What meetings do I have this week? + - Meeting 1, Meeting 2 +- Cancel that first meeting + + +### Or, doing multiple things: +- Cancel all my meetings tomorrow then book a quick chat with @joe at noon, a long meeting with @jane at 1 (both tomorrow). Also see if Toby is down to grab dinner this week (toby@gmail.com). Finally send me my updated agenda of everything I have coming up in the next couple days. \ No newline at end of file diff --git a/packages/app-store/cal-ai/api/_getAdd.ts b/packages/app-store/cal-ai/api/_getAdd.ts new file mode 100644 index 0000000000..d8e13972c6 --- /dev/null +++ b/packages/app-store/cal-ai/api/_getAdd.ts @@ -0,0 +1,26 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; + +import checkSession from "../../_utils/auth"; +import { withPaidAppRedirect } from "../../_utils/paid-apps"; +import appConfig from "../config.json"; + +export async function getHandler(req: NextApiRequest, res: NextApiResponse) { + const session = checkSession(req); + + const redirectUrl = await withPaidAppRedirect({ + appPaidMode: appConfig.paid.mode, + appSlug: appConfig.slug, + userId: session.user.id, + priceId: appConfig.paid.priceId, + }); + + if (!redirectUrl) { + return res.status(500).json({ message: "Failed to create Stripe checkout session" }); + } + + return { url: redirectUrl }; +} + +export default defaultResponder(getHandler); diff --git a/packages/app-store/cal-ai/api/_getCallback.ts b/packages/app-store/cal-ai/api/_getCallback.ts new file mode 100644 index 0000000000..e424d9bde7 --- /dev/null +++ b/packages/app-store/cal-ai/api/_getCallback.ts @@ -0,0 +1,65 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import { createContext } from "@calcom/trpc/server/createContext"; +import { apiKeysRouter } from "@calcom/trpc/server/routers/viewer/apiKeys/_router"; + +import checkSession from "../../_utils/auth"; +import getInstalledAppPath from "../../_utils/getInstalledAppPath"; +import { checkInstalled, createDefaultInstallation } from "../../_utils/installation"; +import { withStripeCallback } from "../../_utils/paid-apps"; +import appConfig from "../config.json"; + +export async function getHandler(req: NextApiRequest, res: NextApiResponse) { + const session = checkSession(req); + const slug = appConfig.slug; + const appType = appConfig.type; + + const { checkoutId } = req.query as { checkoutId: string }; + if (!checkoutId) { + return { url: `/apps/installed?error=${JSON.stringify({ message: "No Stripe Checkout Session ID" })}` }; + } + + const { url } = await withStripeCallback(checkoutId, slug, async ({ checkoutSession }) => { + const ctx = await createContext({ req, res }); + const caller = apiKeysRouter.createCaller(ctx); + + const apiKey = await caller.create({ + note: "Cal.ai", + expiresAt: null, + appId: "cal-ai", + }); + + await checkInstalled(slug, session.user.id); + await createDefaultInstallation({ + appType, + user: session.user, + slug, + key: { + apiKey, + }, + subscriptionId: checkoutSession.subscription?.toString(), + billingCycleStart: new Date().getDate(), + paymentStatus: "active", + }); + + await fetch( + `${process.env.NODE_ENV === "development" ? "http://localhost:3005" : "https://cal.ai"}/api/onboard`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + userId: session.user.id, + }), + } + ); + + return { url: getInstalledAppPath({ variant: appConfig.variant, slug: "cal-ai" }) }; + }); + + return res.redirect(url); +} + +export default defaultResponder(getHandler); diff --git a/packages/app-store/cal-ai/api/add.ts b/packages/app-store/cal-ai/api/add.ts new file mode 100644 index 0000000000..912126e232 --- /dev/null +++ b/packages/app-store/cal-ai/api/add.ts @@ -0,0 +1,5 @@ +import { defaultHandler } from "@calcom/lib/server"; + +export default defaultHandler({ + GET: import("./_getAdd"), +}); diff --git a/packages/app-store/cal-ai/api/callback.ts b/packages/app-store/cal-ai/api/callback.ts new file mode 100644 index 0000000000..07586cf85f --- /dev/null +++ b/packages/app-store/cal-ai/api/callback.ts @@ -0,0 +1,5 @@ +import { defaultHandler } from "@calcom/lib/server"; + +export default defaultHandler({ + GET: import("./_getCallback"), +}); diff --git a/packages/app-store/cal-ai/api/index.ts b/packages/app-store/cal-ai/api/index.ts new file mode 100644 index 0000000000..eb12c1b4ed --- /dev/null +++ b/packages/app-store/cal-ai/api/index.ts @@ -0,0 +1,2 @@ +export { default as add } from "./add"; +export { default as callback } from "./callback"; diff --git a/packages/app-store/cal-ai/components/AppSettingsInterface.tsx b/packages/app-store/cal-ai/components/AppSettingsInterface.tsx new file mode 100644 index 0000000000..8c40985f6e --- /dev/null +++ b/packages/app-store/cal-ai/components/AppSettingsInterface.tsx @@ -0,0 +1,73 @@ +import { useSession } from "next-auth/react"; + +export function MailLink({ subject, body }: { subject: string; body?: string }) { + const { data } = useSession(); + + return ( + + {subject ? subject : body} + + ); +} +export default function AppSettings() { + return ( + <> +
+

Example questions:

+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+

Or, any of that in a thread:

+
    +
  • + What meetings do I have this week? +
      +
    • Meeting 1, Meeting 2
    • +
    +
  • +
  • Cancel that first meeting
  • +
+

Or, doing multiple things:

+
    +
  • + +
  • +
+
+ + ); +} diff --git a/packages/app-store/cal-ai/config.json b/packages/app-store/cal-ai/config.json new file mode 100644 index 0000000000..c13c045312 --- /dev/null +++ b/packages/app-store/cal-ai/config.json @@ -0,0 +1,23 @@ +{ + "/*": "Don't modify slug - If required, do it using cli edit command", + "name": "Cal.ai", + "slug": "cal-ai", + "type": "cal-ai_automation", + "logo": "icon.png", + "url": "https://cal.ai", + "variant": "automation", + "categories": ["automation"], + "publisher": "Cal.com, Inc.", + "email": "support@cal.com", + "description": "Cal.ai is your AI scheduling assistant. Get your personal email assistant (username@cal.ai) that you can forward emails to or have a conversation with. Cal.ai will automatically schedule meetings for you.", + "isTemplate": false, + "__createdUsingCli": true, + "__template": "basic", + "dirName": "cal-ai", + "isOAuth": false, + "paid": { + "priceInUsd": 8, + "priceId": "price_1O1ziDH8UDiwIftkDHp3MCTP", + "mode": "subscription" + } +} diff --git a/packages/app-store/cal-ai/index.ts b/packages/app-store/cal-ai/index.ts new file mode 100644 index 0000000000..d7f3602204 --- /dev/null +++ b/packages/app-store/cal-ai/index.ts @@ -0,0 +1 @@ +export * as api from "./api"; diff --git a/packages/app-store/cal-ai/package.json b/packages/app-store/cal-ai/package.json new file mode 100644 index 0000000000..3cde71d537 --- /dev/null +++ b/packages/app-store/cal-ai/package.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "private": true, + "name": "@calcom/cal-ai", + "version": "0.0.0", + "main": "./index.ts", + "dependencies": { + "@calcom/lib": "*" + }, + "devDependencies": { + "@calcom/types": "*" + }, + "description": "Cal.ai is your AI scheduling assistant. Get your personal email assistant (username@cal.ai) that you can forward emails to or have a conversation with. Cal.ai will automatically schedule meetings for you." +} diff --git a/packages/app-store/cal-ai/static/1.jpg b/packages/app-store/cal-ai/static/1.jpg new file mode 100644 index 0000000000..9792aedc37 Binary files /dev/null and b/packages/app-store/cal-ai/static/1.jpg differ diff --git a/packages/app-store/cal-ai/static/2.jpg b/packages/app-store/cal-ai/static/2.jpg new file mode 100644 index 0000000000..050acf433f Binary files /dev/null and b/packages/app-store/cal-ai/static/2.jpg differ diff --git a/packages/app-store/cal-ai/static/3.jpg b/packages/app-store/cal-ai/static/3.jpg new file mode 100644 index 0000000000..fab5b1ef18 Binary files /dev/null and b/packages/app-store/cal-ai/static/3.jpg differ diff --git a/packages/app-store/cal-ai/static/4.jpg b/packages/app-store/cal-ai/static/4.jpg new file mode 100644 index 0000000000..0b0fbbfde9 Binary files /dev/null and b/packages/app-store/cal-ai/static/4.jpg differ diff --git a/packages/app-store/cal-ai/static/5.jpg b/packages/app-store/cal-ai/static/5.jpg new file mode 100644 index 0000000000..ccb8475bdd Binary files /dev/null and b/packages/app-store/cal-ai/static/5.jpg differ diff --git a/packages/app-store/cal-ai/static/icon.png b/packages/app-store/cal-ai/static/icon.png new file mode 100644 index 0000000000..c63758d416 Binary files /dev/null and b/packages/app-store/cal-ai/static/icon.png differ