Skip to content

Commit 393411a

Browse files
DexterStoreynicktrntedspareemrysal
authored
feat: cal ai (#10992)
Co-authored-by: nicktrn <[email protected]> Co-authored-by: tedspare <[email protected]> Co-authored-by: Alex van Andel <[email protected]>
1 parent 356117f commit 393411a

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+4237
-107
lines changed

apps/ai/.env.example

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
BACKEND_URL=http://localhost:3002/api
2+
3+
APP_ID=cal-ai
4+
APP_URL=http://localhost:3000/apps/cal-ai
5+
6+
# Used to verify requests from sendgrid. You can generate a new one with: `openssl rand -hex 32`
7+
PARSE_KEY=
8+
9+
OPENAI_API_KEY=
10+
11+
# Optionally trace completions at https://smith.langchain.com
12+
# LANGCHAIN_TRACING_V2=true
13+
# LANGCHAIN_ENDPOINT=
14+
# LANGCHAIN_API_KEY=
15+
# LANGCHAIN_PROJECT=

apps/ai/README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Cal.com Email Assistant
2+
3+
Welcome to the first stage of Cal AI!
4+
5+
This app lets you chat with your calendar via email:
6+
7+
- Turn informal emails into bookings eg. forward "wanna meet tmrw at 2pm?"
8+
- List and rearrange your bookings eg. "Cancel my next meeting"
9+
- Answer basic questions about your busiest times eg. "How does my Tuesday look?"
10+
11+
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.
12+
13+
_The AI agent can only choose from a set of tools, without ever seeing your API key._
14+
15+
Emails are cleaned and routed in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts) using [MailParser](https://nodemailer.com/extras/mailparser/).
16+
17+
Incoming emails are routed by email address. Addresses are verified by [DKIM record](https://support.google.com/a/answer/174124?hl=en), making it hard to spoof them.
18+
19+
## Getting Started
20+
21+
### Development
22+
23+
If you haven't yet, please run the [root setup](/README.md) steps.
24+
25+
Before running the app, please see [env.mjs](./src/env.mjs) for all required environment variables. You'll need:
26+
27+
- An [OpenAI API key](https://platform.openai.com/account/api-keys) with access to GPT-4
28+
- A [SendGrid API key](https://app.sendgrid.com/settings/api_keys)
29+
- A default sender email (for example, `[email protected]`)
30+
- The Cal AI's app ID and URL (see [add.ts](/packages/app-store/cal-ai/api/index.ts))
31+
32+
To stand up the API and AI apps simultaneously, simply run `yarn dev:ai`.
33+
34+
### Email Router
35+
36+
To expose the AI app, run `ngrok http 3000` (or the AI app's port number) in a new terminal. You may need to install [nGrok](https://ngrok.com/).
37+
38+
To forward incoming emails to the Node.js server, one option is to use [SendGrid's Inbound Parse Webhook](https://docs.sendgrid.com/for-developers/parsing-email/setting-up-the-inbound-parse-webhook).
39+
40+
1. [Sign up for an account](https://signup.sendgrid.com/)
41+
2. Go to Settings > [Inbound Parse](https://app.sendgrid.com/settings/parse) > Add Host & URL.
42+
3. For subdomain, use `<sub>.<domain>.com` for now, where `sub` can be any subdomain but `domain.com` will need to be verified via MX records in your environment variables, eg. on [Vercel](https://vercel.com/guides/how-to-add-vercel-environment-variables).
43+
4. Use the nGrok URL from above as the **Destination URL**.
44+
5. Activate "POST the raw, full MIME message".
45+
6. Send an email to `<anyone>@ai.example.com`. You should see a ping on the nGrok listener and Node.js server.
46+
7. 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.
47+
48+
Please feel free to improve any part of this architecture.

apps/ai/next-env.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/// <reference types="next" />
2+
/// <reference types="next/image-types/global" />
3+
4+
// NOTE: This file should not be edited
5+
// see https://nextjs.org/docs/basic-features/typescript for more information.

apps/ai/next.config.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
const withBundleAnalyzer = require("@next/bundle-analyzer");
2+
3+
const plugins = [];
4+
plugins.push(withBundleAnalyzer({ enabled: process.env.ANALYZE === "true" }));
5+
6+
/** @type {import("next").NextConfig} */
7+
const nextConfig = {
8+
i18n: {
9+
defaultLocale: "en",
10+
locales: ["en"],
11+
},
12+
reactStrictMode: true,
13+
};
14+
15+
module.exports = () => plugins.reduce((acc, next) => next(acc), nextConfig);

apps/ai/package.json

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"name": "@calcom/ai",
3+
"version": "0.1.0",
4+
"private": true,
5+
"author": "Cal.com Inc.",
6+
"dependencies": {
7+
"@calcom/prisma": "*",
8+
"@t3-oss/env-nextjs": "^0.6.1",
9+
"langchain": "^0.0.131",
10+
"mailparser": "^3.6.5",
11+
"next": "^13.4.6",
12+
"zod": "^3.20.2"
13+
},
14+
"devDependencies": {
15+
"@types/mailparser": "^3.4.0",
16+
"@types/node": "^20.5.1",
17+
"typescript": "^4.9.4"
18+
},
19+
"scripts": {
20+
"build": "next build",
21+
"dev": "next dev -p 3005",
22+
"format": "npx prettier . --write",
23+
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
24+
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
25+
"start": "next start"
26+
}
27+
}

apps/ai/src/app/api/agent/route.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { NextRequest } from "next/server";
2+
import { NextResponse } from "next/server";
3+
4+
import agent from "../../../utils/agent";
5+
import sendEmail from "../../../utils/sendEmail";
6+
import { verifyParseKey } from "../../../utils/verifyParseKey";
7+
8+
/**
9+
* Launches a LangChain agent to process an incoming email,
10+
* then sends the response to the user.
11+
*/
12+
export const POST = async (request: NextRequest) => {
13+
const verified = verifyParseKey(request.url);
14+
15+
if (!verified) {
16+
return new NextResponse("Unauthorized", { status: 401 });
17+
}
18+
19+
const json = await request.json();
20+
21+
const { apiKey, userId, message, subject, user, replyTo } = json;
22+
23+
if ((!message && !subject) || !user) {
24+
return new NextResponse("Missing fields", { status: 400 });
25+
}
26+
27+
try {
28+
const response = await agent(`${subject}\n\n${message}`, user, apiKey, userId);
29+
30+
// Send response to user
31+
await sendEmail({
32+
subject: `Re: ${subject}`,
33+
text: response.replace(/(?:\r\n|\r|\n)/g, "\n"),
34+
to: user.email,
35+
from: replyTo,
36+
});
37+
38+
return new NextResponse("ok");
39+
} catch (error) {
40+
return new NextResponse(
41+
(error as Error).message || "Something went wrong. Please try again or reach out for help.",
42+
{ status: 500 }
43+
);
44+
}
45+
};
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import type { ParsedMail, Source } from "mailparser";
2+
import { simpleParser } from "mailparser";
3+
import type { NextRequest } from "next/server";
4+
import { NextResponse } from "next/server";
5+
6+
import prisma from "@calcom/prisma";
7+
8+
import { env } from "../../../env.mjs";
9+
import { fetchAvailability } from "../../../tools/getAvailability";
10+
import { fetchEventTypes } from "../../../tools/getEventTypes";
11+
import getHostFromHeaders from "../../../utils/host";
12+
import now from "../../../utils/now";
13+
import sendEmail from "../../../utils/sendEmail";
14+
import { verifyParseKey } from "../../../utils/verifyParseKey";
15+
16+
/**
17+
* Verifies email signature and app authorization,
18+
* then hands off to booking agent.
19+
*/
20+
export const POST = async (request: NextRequest) => {
21+
const verified = verifyParseKey(request.url);
22+
23+
if (!verified) {
24+
return new NextResponse("Unauthorized", { status: 401 });
25+
}
26+
27+
const formData = await request.formData();
28+
const body = Object.fromEntries(formData);
29+
30+
// body.dkim looks like {@domain-com.22222222.gappssmtp.com : pass}
31+
const signature = (body.dkim as string).includes(" : pass");
32+
33+
const envelope = JSON.parse(body.envelope as string);
34+
35+
const aiEmail = envelope.to[0];
36+
37+
// Parse email from mixed MIME type
38+
const parsed: ParsedMail = await simpleParser(body.email as Source);
39+
40+
if (!parsed.text && !parsed.subject) {
41+
return new NextResponse("Email missing text and subject", { status: 400 });
42+
}
43+
44+
const user = await prisma.user.findUnique({
45+
select: {
46+
email: true,
47+
id: true,
48+
credentials: {
49+
select: {
50+
appId: true,
51+
key: true,
52+
},
53+
},
54+
},
55+
where: { email: envelope.from },
56+
});
57+
58+
if (!signature || !user?.email || !user?.id) {
59+
await sendEmail({
60+
subject: `Re: ${body.subject}`,
61+
text: "Sorry, you are not authorized to use this service. Please verify your email address and try again.",
62+
to: user?.email || "",
63+
from: aiEmail,
64+
});
65+
66+
return new NextResponse();
67+
}
68+
69+
const credential = user.credentials.find((c) => c.appId === env.APP_ID)?.key;
70+
71+
// User has not installed the app from the app store. Direct them to install it.
72+
if (!(credential as { apiKey: string })?.apiKey) {
73+
const url = env.APP_URL;
74+
75+
await sendEmail({
76+
html: `Thanks for using Cal AI! To get started, the app must be installed. <a href=${url} target="_blank">Click this link</a> to install it.`,
77+
subject: `Re: ${body.subject}`,
78+
text: `Thanks for using Cal AI! To get started, the app must be installed. Click this link to install the Cal AI app: ${url}`,
79+
to: envelope.from,
80+
from: aiEmail,
81+
});
82+
83+
return new NextResponse("ok");
84+
}
85+
86+
const { apiKey } = credential as { apiKey: string };
87+
88+
// Pre-fetch data relevant to most bookings.
89+
const [eventTypes, availability] = await Promise.all([
90+
fetchEventTypes({
91+
apiKey,
92+
}),
93+
fetchAvailability({
94+
apiKey,
95+
userId: user.id,
96+
dateFrom: now,
97+
dateTo: now,
98+
}),
99+
]);
100+
101+
if ("error" in availability) {
102+
await sendEmail({
103+
subject: `Re: ${body.subject}`,
104+
text: "Sorry, there was an error fetching your availability. Please try again.",
105+
to: user.email,
106+
from: aiEmail,
107+
});
108+
console.error(availability.error);
109+
return new NextResponse("Error fetching availability. Please try again.", { status: 400 });
110+
}
111+
112+
if ("error" in eventTypes) {
113+
await sendEmail({
114+
subject: `Re: ${body.subject}`,
115+
text: "Sorry, there was an error fetching your event types. Please try again.",
116+
to: user.email,
117+
from: aiEmail,
118+
});
119+
console.error(eventTypes.error);
120+
return new NextResponse("Error fetching event types. Please try again.", { status: 400 });
121+
}
122+
123+
const { timeZone, workingHours } = availability;
124+
125+
const appHost = getHostFromHeaders(request.headers);
126+
127+
// Hand off to long-running agent endpoint to handle the email. (don't await)
128+
fetch(`${appHost}/api/agent?parseKey=${env.PARSE_KEY}`, {
129+
body: JSON.stringify({
130+
apiKey,
131+
userId: user.id,
132+
message: parsed.text,
133+
subject: parsed.subject,
134+
replyTo: aiEmail,
135+
user: {
136+
email: user.email,
137+
eventTypes,
138+
timeZone,
139+
workingHours,
140+
},
141+
}),
142+
headers: {
143+
"Content-Type": "application/json",
144+
},
145+
method: "POST",
146+
});
147+
148+
await new Promise((r) => setTimeout(r, 1000));
149+
150+
return new NextResponse("ok");
151+
};

apps/ai/src/env.mjs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { createEnv } from "@t3-oss/env-nextjs";
2+
import { z } from "zod";
3+
4+
export const env = createEnv({
5+
/**
6+
* Specify your client-side environment variables schema here. This way you can ensure the app
7+
* isn't built with invalid env vars. To expose them to the client, prefix them with
8+
* `NEXT_PUBLIC_`.
9+
*/
10+
client: {
11+
// NEXT_PUBLIC_CLIENTVAR: z.string().min(1),
12+
},
13+
14+
/**
15+
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
16+
* middlewares) or client-side so we need to destruct manually.
17+
*/
18+
runtimeEnv: {
19+
BACKEND_URL: process.env.BACKEND_URL,
20+
APP_ID: process.env.APP_ID,
21+
APP_URL: process.env.APP_URL,
22+
PARSE_KEY: process.env.PARSE_KEY,
23+
NODE_ENV: process.env.NODE_ENV,
24+
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
25+
SENDGRID_API_KEY: process.env.SENDGRID_API_KEY,
26+
},
27+
28+
/**
29+
* Specify your server-side environment variables schema here. This way you can ensure the app
30+
* isn't built with invalid env vars.
31+
*/
32+
server: {
33+
BACKEND_URL: z.string().url(),
34+
APP_ID: z.string().min(1),
35+
APP_URL: z.string().url(),
36+
PARSE_KEY: z.string().min(1),
37+
NODE_ENV: z.enum(["development", "test", "production"]),
38+
OPENAI_API_KEY: z.string().min(1),
39+
SENDGRID_API_KEY: z.string().min(1),
40+
},
41+
});

0 commit comments

Comments
 (0)