feat: cal ai templates (#15391)

* chore: save progress

* feat: add schema and fieds

* chore: update code

* chore: save

* refactor: code and type error

* feat: use facade + command design pattern

* chore: missing param

* feat: add custom template feature

* refactor: code

* chore: move the default prompt

* fix: type err

* fix: code and ux improvements

* chore: change default template

* fix: ui and typo

* fix: add validation check for my phone number

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
This commit is contained in:
Udit Takkar
2024-07-30 13:09:12 +02:00
committed by GitHub
co-authored by Joe Au-Yeung
parent c6febad364
commit 199d95b35f
17 changed files with 759 additions and 340 deletions
@@ -1,14 +1,18 @@
import * as RadioGroup from "@radix-ui/react-radio-group";
import { useSession } from "next-auth/react";
import type { EventTypeSetup } from "pages/event-types/[type]";
import { useState } from "react";
import React, { useState } from "react";
import { useFormContext, Controller } from "react-hook-form";
import { z } from "zod";
import { getTemplateFieldsSchema } from "@calcom/features/ee/cal-ai-phone/getTemplateFieldsSchema";
import { TEMPLATES_FIELDS } from "@calcom/features/ee/cal-ai-phone/template-fields-map";
import type { TemplateType } from "@calcom/features/ee/cal-ai-phone/zod-utils";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
import { ComponentForField } from "@calcom/features/form-builder/FormBuilderField";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { AIPhoneSettingSchema } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
import {
Button,
@@ -17,9 +21,9 @@ import {
SettingsToggle,
Divider,
TextField,
TextAreaField,
PhoneInput,
showToast,
Icon,
} from "@calcom/ui";
type AIEventControllerProps = {
@@ -85,10 +89,64 @@ export default function AIEventController({ eventType, isTeamEvent }: AIEventCon
);
}
const ErrorMessage = ({ fieldName, message }: { fieldName: string; message: string }) => {
const { t } = useLocale();
return (
<div data-testid={`error-message-${fieldName}`} className="mt-2 flex items-center text-sm text-red-700 ">
<Icon name="info" className="h-3 w-3 ltr:mr-2 rtl:ml-2" />
<p>{t(message || "invalid_input")}</p>
</div>
);
};
const TemplateFields = () => {
const { t } = useLocale();
const formMethods = useFormContext<FormValues>();
const { control, watch } = formMethods;
const templateType = watch("aiPhoneCallConfig.templateType");
const fields = TEMPLATES_FIELDS[templateType as TemplateType];
return (
<div className="space-y-4">
{fields?.map((field) => (
<div key={field.name}>
<Controller
control={control}
name={`aiPhoneCallConfig.${field.name}`}
render={({ field: { value, onChange }, fieldState: { error } }) => {
const { variableName, ...restField } = field;
const variableInfo = !!variableName ? `: ${t("variable")} {{${variableName}}}` : "";
return (
<div>
<ComponentForField
field={{
...restField,
label: `${t(field.defaultLabel)}${variableInfo}`,
placeholder: t(field.placeholder),
}}
value={value ?? ""}
readOnly={false}
setValue={(val: unknown) => {
onChange(val);
}}
/>
{error?.message && <ErrorMessage message={error.message} fieldName={field.name} />}
</div>
);
}}
/>
</div>
))}
</div>
);
};
const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
const { t } = useLocale();
const formMethods = useFormContext<FormValues>();
const [calApiKey, setCalApiKey] = useState("");
const createCallMutation = trpc.viewer.organizations.createPhoneCall.useMutation({
@@ -98,43 +156,25 @@ const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
}
},
onError: (err) => {
showToast(t("something_went_wrong"), "error");
const message = err?.message ? err.message : t("something_went_wrong");
showToast(message, "error");
},
});
// const [createModalOpen, setCreateModalOpen] = useState(false);
// const NewPhoneButton = () => {
// const { t } = useLocale();
// return (
// <Button
// color="primary"
// data-testid="new_phone_number"
// StartIcon={Plus}
// onClick={() => setCreateModalOpen(true)}>
// {t("New Phone number")}
// </Button>
// );
// };
// v1 will require the user to log in to Retellai.com to create a phone number, and an agent and
// authorize it with the Cal.com API key / OAuth
// const retellAuthorized = true; // TODO: call retellAPI here
const handleSubmit = async () => {
try {
const values = formMethods.getValues("aiPhoneCallConfig");
const { templateType } = values;
const data = await AIPhoneSettingSchema.parseAsync({
generalPrompt: values.generalPrompt,
beginMessage: values.beginMessage,
enabled: values.enabled,
guestName: values.guestName,
guestEmail: values.guestEmail.trim().length ? values.guestEmail : undefined,
guestCompany: values.guestCompany.trim().length ? values.guestCompany : undefined,
const schema = getTemplateFieldsSchema({ templateType });
const data = schema.parse({
...values,
guestEmail: values.guestEmail && values.guestEmail.trim().length ? values.guestEmail : undefined,
guestCompany:
values.guestCompany && values.guestCompany.trim().length ? values.guestCompany : undefined,
guestName: values.guestName && values.guestName.trim().length ? values.guestName : undefined,
eventTypeId: eventType.id,
numberToCall: values.numberToCall,
yourPhoneNumber: values.yourPhoneNumber,
calApiKey,
});
@@ -144,6 +184,15 @@ const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
const fieldName = err.issues?.[0]?.path?.[0];
const message = err.issues?.[0]?.message;
showToast(`Error on ${fieldName}: ${message} `, "error");
const issues = err.issues;
for (const issue of issues) {
const fieldName = `aiPhoneCallConfig.${issue.path[0]}` as unknown as keyof FormValues;
formMethods.setError(fieldName, {
type: "custom",
message: issue.message,
});
}
} else {
showToast(t("something_went_wrong"), "error");
}
@@ -157,68 +206,89 @@ const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
<Label>{t("your_phone_number")}</Label>
<Controller
name="aiPhoneCallConfig.yourPhoneNumber"
render={({ field: { onChange, value } }) => {
render={({ field: { onChange, value, name }, fieldState: { error } }) => {
return (
<PhoneInput
required
placeholder={t("your_phone_number")}
id="aiPhoneCallConfig.yourPhoneNumber"
name="aiPhoneCallConfig.yourPhoneNumber"
value={value}
onChange={(val) => {
onChange(val);
}}
/>
<div>
<PhoneInput
required
placeholder={t("your_phone_number")}
id="aiPhoneCallConfig.yourPhoneNumber"
name="aiPhoneCallConfig.yourPhoneNumber"
value={value}
onChange={(val) => {
onChange(val);
}}
/>
{error?.message && <ErrorMessage message={error.message} fieldName={name} />}
</div>
);
}}
/>
<>
<RadioGroup.Root
defaultValue={eventType?.aiPhoneCallConfig?.templateType ?? "CUSTOM_TEMPLATE"}
onValueChange={(val) => {
formMethods.setValue("aiPhoneCallConfig.templateType", val, { shouldDirty: true });
}}>
<div className="flex gap-2">
<div className="flex flex-1 rounded-lg border p-4">
<RadioGroup.Item
id="CHECK_IN_APPOINTMENT"
key="CHECK_IN_APPOINTMENT"
value="CHECK_IN_APPOINTMENT"
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2 ">
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
</RadioGroup.Item>
<label className="h-full" htmlFor="CHECK_IN_APPOINTMENT">
<h2 className="font-semibold leading-tight">{t("check_in_assistant")}</h2>
<p className="text-subtle mt-2">{t("check_in_assistant_description")}</p>
</label>
</div>
<div className="flex flex-1 rounded-lg border p-4">
<RadioGroup.Item
id="CUSTOM_TEMPLATE"
key="CUSTOM_TEMPLATE"
value="CUSTOM_TEMPLATE"
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
</RadioGroup.Item>
<label htmlFor="CUSTOM_TEMPLATE">
<h2 className="font-semibold leading-tight">{t("custom_template")}</h2>
<p className="text-subtle mt-2">{t("create_your_own_prompt")}</p>
</label>
</div>
</div>
</RadioGroup.Root>
</>
<Label>{t("number_to_call")}</Label>
<Controller
name="aiPhoneCallConfig.numberToCall"
render={({ field: { onChange, value } }) => {
render={({ field: { onChange, value, name }, fieldState: { error } }) => {
return (
<PhoneInput
required
placeholder={t("phone_number")}
id="aiPhoneCallConfig.numberToCall"
name="aiPhoneCallConfig.numberToCall"
value={value}
onChange={(val) => {
onChange(val);
}}
/>
<div>
<PhoneInput
required
placeholder={t("phone_number")}
id="aiPhoneCallConfig.numberToCall"
name="aiPhoneCallConfig.numberToCall"
value={value}
onChange={(val) => {
onChange(val);
}}
/>
{error?.message && <ErrorMessage message={error.message} fieldName={name} />}
</div>
);
}}
/>
<TemplateFields />
<Divider />
</>
<TextField
type="text"
hint="Variable: {{name}}"
label={t("guest_name")}
placeholder="Jane Doe"
{...formMethods.register("aiPhoneCallConfig.guestName")}
/>
<TextField
type="text"
hint="Variable: {{email}}"
label={t("guest_email")}
placeholder="jane@acme.com"
{...formMethods.register("aiPhoneCallConfig.guestEmail")}
/>
<TextField
type="text"
hint="Variable: {{company}}"
label={t("guest_company")}
placeholder="Acme"
{...formMethods.register("aiPhoneCallConfig.guestCompany")}
/>
<TextField
type="text"
hint="For eg:- cal_live_0123.."
@@ -232,26 +302,6 @@ const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
/>
<Divider />
<TextAreaField
rows={3}
required
placeholder={t("general_prompt")}
label={t("general_prompt")}
{...formMethods.register("aiPhoneCallConfig.generalPrompt")}
onChange={(e) => {
formMethods.setValue("aiPhoneCallConfig.generalPrompt", e.target.value, { shouldDirty: true });
}}
/>
<TextAreaField
rows={3}
placeholder={t("begin_message")}
label={t("begin_message")}
{...formMethods.register("aiPhoneCallConfig.beginMessage")}
onChange={(e) => {
formMethods.setValue("aiPhoneCallConfig.beginMessage", e.target.value, { shouldDirty: true });
}}
/>
<Button
disabled={createCallMutation.isPending}
@@ -268,34 +318,6 @@ const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
and learn how to build workflows.
</small> */}
</div>
{/* TODO:
<>
<EmptyScreen
Icon={Phone}
headline={t("Create your phone number")}
description={t(
"This phone number can be called by guests but can also do proactive outbound calls by the AI agent."
)}
buttonRaw={
<div className="flex justify-between gap-2">
<NewPhoneButton />
<Button color="secondary">{t("learn_more")}</Button>
</div>
}
/>
<Dialog open={createModalOpen} onOpenChange={(isOpen) => !isOpen && setCreateModalOpen(false)}>
<DialogContent
enableOverflow
title={t("Create phone number")}
description={t("This number can later be called or can do proactive outbound calls")}>
<div className="mb-12 mt-4">
<TextField placeholder="+415" hint="Area Code" />
</div>
</DialogContent>
</Dialog>
</>
*/}
</div>
);
};
@@ -15,6 +15,10 @@ import { z } from "zod";
import checkForMultiplePaymentApps from "@calcom/app-store/_utils/payments/checkForMultiplePaymentApps";
import { getEventLocationType } from "@calcom/app-store/locations";
import { validateCustomEventName } from "@calcom/core/event";
import {
DEFAULT_PROMPT_VALUE,
DEFAULT_BEGIN_MESSAGE,
} from "@calcom/features/ee/cal-ai-phone/promptTemplates";
import type { Workflow } from "@calcom/features/ee/workflows/lib/types";
import type { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
@@ -40,42 +44,6 @@ import { EventTypeSingleLayout } from "@components/eventtype/EventTypeSingleLayo
import { type PageProps } from "~/event-types/views/event-types-single-view.getServerSideProps";
const DEFAULT_PROMPT_VALUE = `## You are helping user set up a call with the support team. The appointment is 15 min long. You are a pleasant and friendly.
## Style Guardrails
Be Concise: Respond succinctly, addressing one topic at most.
Embrace Variety: Use diverse language and rephrasing to enhance clarity without repeating content.
Be Conversational: Use everyday language, making the chat feel like talking to a friend.
Be Proactive: Lead the conversation, often wrapping up with a question or next-step suggestion.
Avoid multiple questions in a single response.
Get clarity: If the user only partially answers a question, or if the answer is unclear, keep asking to get clarity.
Use a colloquial way of referring to the date (like Friday, Jan 14th, or Tuesday, Jan 12th, 2024 at 8am).
If you are saying a time like 8:00 AM, just say 8 AM and emit the trailing zeros.
## Response Guideline
Adapt and Guess: Try to understand transcripts that may contain transcription errors. Avoid mentioning \"transcription error\" in the response.
Stay in Character: Keep conversations within your role'''s scope, guiding them back creatively without repeating.
Ensure Fluid Dialogue: Respond in a role-appropriate, direct manner to maintain a smooth conversation flow.
## Schedule Rule
Current time is {{current_time}}. You only schedule time in current calendar year, you cannot schedule time that'''s in the past.
## Task Steps
1. I am here to learn more about your issue and help schedule an appointment with our support team.
2. If {{email}} is not unknown then Use name {{name}} and email {{email}} for creating booking else Ask for user name and email and Confirm the name and email with user by reading it back to user.
3. Ask user for \"When would you want to meet with one of our representive\".
4. Call function check_availability to check for availability in the user provided time range.
- if availability exists, inform user about the availability range (do not repeat the detailed available slot) and ask user to choose from it. Make sure user chose a slot within detailed available slot.
- if availability does not exist, ask user to select another time range for the appointment, repeat this step 3.
5. Confirm the date and time selected by user: \"Just to confirm, you want to book the appointment at ...\".
6. Once confirmed, call function book_appointment to book the appointment.
- if booking returned booking detail, it means booking is successful, proceed to step 7.
- if booking returned error message, let user know why the booking was not successful, and maybe start over with step 3.
7. Inform the user booking is successful, and ask if user have any questions. Answer them if there are any.
8. After all questions answered, call function end_call to hang up.`;
const DEFAULT_BEGIN_MESSAGE = "Hi. How are you doing?";
// These can't really be moved into calcom/ui due to the fact they use infered getserverside props typings;
const EventSetupTab = dynamic(() =>
import("@components/eventtype/EventSetupTab").then((mod) => mod.EventSetupTab)
@@ -372,6 +340,8 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf
guestCompany: eventType.aiPhoneCallConfig?.guestCompany,
yourPhoneNumber: eventType.aiPhoneCallConfig?.yourPhoneNumber,
numberToCall: eventType.aiPhoneCallConfig?.numberToCall,
templateType: eventType.aiPhoneCallConfig?.templateType ?? "CUSTOM_TEMPLATE",
schedulerName: eventType.aiPhoneCallConfig?.schedulerName,
},
};
}, [eventType, periodDates, metadata]);
@@ -3,11 +3,11 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { ZGetRetellLLMSchema } from "@calcom/features/ee/cal-ai-phone/zod-utils";
import type { TGetRetellLLMSchema } from "@calcom/features/ee/cal-ai-phone/zod-utils";
import { fetcher } from "@calcom/lib/retellAIFetcher";
import { defaultHandler } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import { getRetellLLMSchema } from "@calcom/prisma/zod-utils";
import type { TGetRetellLLMSchema } from "@calcom/prisma/zod-utils";
import { getAvailableSlots } from "@calcom/trpc/server/routers/viewer/slots/util";
dayjs.extend(advancedFormat);
@@ -53,7 +53,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const body = response.data;
const retellLLM = await fetcher(`/get-retell-llm/${body.llm_id}`).then(getRetellLLMSchema.parse);
const retellLLM = await fetcher(`/get-retell-llm/${body.llm_id}`).then(ZGetRetellLLMSchema.parse);
const { eventTypeId, timezone } = getEventTypeIdFromRetellLLM(retellLLM);
@@ -103,6 +103,9 @@
"new_event_request": "New event request: {{attendeeName}} - {{date}} - {{eventType}}",
"confirm_or_reject_request": "Confirm or reject the request",
"check_bookings_page_to_confirm_or_reject": "Check your bookings page to confirm or reject the booking.",
"check_in_assistant": "Check-in Assistant",
"check_in_assistant_description": "Makes an outbound call to check if scheduled appointment works or if you want to reschedule.",
"create_your_own_prompt": "Create your own prompt and use it to make an outbound call.",
"event_awaiting_approval": "An event is waiting for your approval",
"event_awaiting_approval_recurring": "A recurring event is waiting for your approval",
"someone_requested_an_event": "Someone has requested to schedule an event on your calendar.",
@@ -2409,6 +2412,7 @@
"confirm_email": "Confirm your email",
"scheduler_first_name": "The first name of the person booking",
"scheduler_last_name": "The last name of the person booking",
"scheduler_name": "Scheduler Name",
"organizer_first_name": "Your first name",
"confirm_email_description": "We sent an email to <strong>{{email}}</strong>. Click the link in the email to verify this address.",
"send_event_details_to": "Send event details to",
@@ -0,0 +1,6 @@
import type { TemplateType } from "./zod-utils";
import { fieldSchemaMap } from "./zod-utils";
export const getTemplateFieldsSchema = ({ templateType }: { templateType: string }) => {
return fieldSchemaMap[templateType as TemplateType];
};
@@ -0,0 +1,62 @@
import { templateTypeEnum } from "./zod-utils";
import type { TemplateType } from "./zod-utils";
export const PROMPT_TEMPLATES: Partial<Record<TemplateType, { generalPrompt: string }>> = {
[templateTypeEnum.enum.CHECK_IN_APPOINTMENT]: {
generalPrompt: `## Identity
You are {{scheduler_name}}'s virtual assistant named Chloe, calling to notify user of a scheduled appointment. You are a pleasant and friendly assistant, here to help manage calendar notifications.
## Style Guardrails
Be Concise: Respond succinctly, addressing one topic at most.
Embrace Variety: Use diverse language and rephrasing to enhance clarity without repeating content.
Be Conversational: Use everyday language, making the chat feel like talking to a friend.
Be Proactive: Lead the conversation, often wrapping up with a question or next-step suggestion.
Avoid multiple questions in a single response.
Get clarity: If the user only partially answers a question, or if the answer is unclear, keep asking to get clarity.
Use a colloquial way of referring to the date (like 'next Friday', 'tomorrow').
One question at a time: Ask only one question at a time, do not pack more topics into one response.
## Response Guideline
Adapt and Guess: Try to understand transcripts that may contain transcription errors. Avoid mentioning "transcription error" in the response.
Stay in Character: Keep conversations within your role's scope, guiding them back creatively without repeating.
Ensure Fluid Dialogue: Respond in a role-appropriate, direct manner to maintain a smooth conversation flow.
Do not make up answers: If you do not know the answer to a question, simply say so. Do not fabricate or deviate from listed responses.
If at any moment the conversation deviates, kindly lead it back to the relevant topic. Do not repeat from start, keep asking from where you stopped.`,
},
};
export const DEFAULT_PROMPT_VALUE = `## You are helping user set up a call with the support team. The appointment is 15 min long. You are a pleasant and friendly.
## Style Guardrails
Be Concise: Respond succinctly, addressing one topic at most.
Embrace Variety: Use diverse language and rephrasing to enhance clarity without repeating content.
Be Conversational: Use everyday language, making the chat feel like talking to a friend.
Be Proactive: Lead the conversation, often wrapping up with a question or next-step suggestion.
Avoid multiple questions in a single response.
Get clarity: If the user only partially answers a question, or if the answer is unclear, keep asking to get clarity.
Use a colloquial way of referring to the date (like Friday, Jan 14th, or Tuesday, Jan 12th, 2024 at 8am).
If you are saying a time like 8:00 AM, just say 8 AM and emit the trailing zeros.
## Response Guideline
Adapt and Guess: Try to understand transcripts that may contain transcription errors. Avoid mentioning \"transcription error\" in the response.
Stay in Character: Keep conversations within your role'''s scope, guiding them back creatively without repeating.
Ensure Fluid Dialogue: Respond in a role-appropriate, direct manner to maintain a smooth conversation flow.
## Schedule Rule
Current time is {{current_time}}. You only schedule time in current calendar year, you cannot schedule time that'''s in the past.
## Task Steps
1. I am here to learn more about your issue and help schedule an appointment with our support team.
2. If {{email}} is not unknown then Use name {{name}} and email {{email}} for creating booking else Ask for user name and email and Confirm the name and email with user by reading it back to user.
3. Ask user for \"When would you want to meet with one of our representive\".
4. Call function check_availability to check for availability in the user provided time range.
- if availability exists, inform user about the availability range (do not repeat the detailed available slot) and ask user to choose from it. Make sure user chose a slot within detailed available slot.
- if availability does not exist, ask user to select another time range for the appointment, repeat this step 3.
5. Confirm the date and time selected by user: \"Just to confirm, you want to book the appointment at ...\".
6. Once confirmed, call function book_appointment to book the appointment.
- if booking returned booking detail, it means booking is successful, proceed to step 7.
- if booking returned error message, let user know why the booking was not successful, and maybe start over with step 3.
7. Inform the user booking is successful, and ask if user have any questions. Answer them if there are any.
8. After all questions answered, call function end_call to hang up.`;
export const DEFAULT_BEGIN_MESSAGE = "Hi. How are you doing?";
@@ -0,0 +1,227 @@
import { WEBAPP_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { fetcher } from "@calcom/lib/retellAIFetcher";
import { safeStringify } from "@calcom/lib/safeStringify";
import type {
TCreateRetellLLMSchema,
TGetRetellLLMSchema,
TCreatePhoneCallSchema,
TemplateType,
TGetPhoneNumberSchema,
TCreatePhoneSchema,
} from "./zod-utils";
import {
ZGetRetellLLMSchema,
ZCreatePhoneSchema,
ZCreateRetellLLMSchema,
ZGetPhoneNumberSchema,
} from "./zod-utils";
const log = logger.getSubLogger({ prefix: ["retellAIService: "] });
type DynamicVariables = Pick<
TCreatePhoneCallSchema,
"guestName" | "guestEmail" | "guestCompany" | "schedulerName"
>;
type initProps = {
templateType: TemplateType;
yourPhoneNumber: string;
eventTypeId: number;
calApiKey: string;
loggedInUserTimeZone: string;
beginMessage?: string;
dynamicVariables: DynamicVariables;
generalPrompt: string;
};
const updateAgentWebsocketUrl = async (phoneNumber: string, llmWebsocketUrl: string): Promise<void> => {
try {
const phoneNumberDetails = await fetcher(`/get-phone-number/${phoneNumber}`).then(
ZGetPhoneNumberSchema.parse
);
await fetcher(`/update-agent/${phoneNumberDetails.outbound_agent_id}`, {
method: "PATCH",
body: JSON.stringify({
llm_websocket_url: llmWebsocketUrl,
}),
});
} catch (error) {
log.error("Unable to Update Agent", safeStringify(error));
throw new Error("Something went wrong! Unable to Update Agent");
}
};
// Command Design Pattern
interface Command<T> {
execute(): Promise<T>;
}
class CreateRetellLLMCommand implements Command<TCreateRetellLLMSchema> {
constructor(private props: initProps) {}
async execute(): Promise<TCreateRetellLLMSchema> {
try {
const createdRetellLLM = await fetcher("/create-retell-llm", {
method: "POST",
body: JSON.stringify({
general_prompt: this.props.generalPrompt,
begin_message: this.props.beginMessage,
inbound_dynamic_variables_webhook_url: `${WEBAPP_URL}/api/get-inbound-dynamic-variables`,
general_tools: [
{
type: "end_call",
name: "end_call",
description: "Hang up the call, triggered only after appointment successfully scheduled.",
},
{
type: "check_availability_cal",
name: "check_availability",
cal_api_key: this.props.calApiKey,
event_type_id: this.props.eventTypeId,
timezone: this.props.loggedInUserTimeZone,
},
{
type: "book_appointment_cal",
name: "book_appointment",
cal_api_key: this.props.calApiKey,
event_type_id: this.props.eventTypeId,
timezone: this.props.loggedInUserTimeZone,
},
],
}),
}).then(ZCreateRetellLLMSchema.parse);
const llmWebSocketUrlToBeUpdated = createdRetellLLM.llm_websocket_url;
await updateAgentWebsocketUrl(this.props.yourPhoneNumber, llmWebSocketUrlToBeUpdated);
return createdRetellLLM;
} catch (error) {
log.error("Unable to Create Retell LLM", safeStringify(error));
throw new Error("Something went wrong! Unable to Create Retell LLM");
}
}
}
class GetRetellLLMCommand implements Command<TGetRetellLLMSchema> {
constructor(private llmId: string) {}
async execute(): Promise<TGetRetellLLMSchema> {
try {
const retellLLM = await fetcher(`/get-retell-llm/${this.llmId}`).then(ZGetRetellLLMSchema.parse);
return retellLLM;
} catch (err) {
log.error("Unable to get Retell LLM", safeStringify(err));
throw new Error("Something went wrong! Unable to get Retell LLM");
}
}
}
class UpdateRetellLLMCommand implements Command<TGetRetellLLMSchema> {
constructor(private props: initProps, private llmId: string) {}
async execute(): Promise<TGetRetellLLMSchema> {
try {
const updatedRetellLLM = await fetcher(`/update-retell-llm/${this.llmId}`, {
method: "PATCH",
body: JSON.stringify({
general_prompt: this.props.generalPrompt,
begin_message: this.props.beginMessage,
inbound_dynamic_variables_webhook_url: `${WEBAPP_URL}/api/get-inbound-dynamic-variables`,
}),
}).then(ZGetRetellLLMSchema.parse);
const llmWebSocketUrlToBeUpdated = updatedRetellLLM.llm_websocket_url;
await updateAgentWebsocketUrl(this.props.yourPhoneNumber, llmWebSocketUrlToBeUpdated);
return updatedRetellLLM;
} catch (err) {
log.error("Unable to Update Retell LLM", safeStringify(err));
throw new Error("Something went wrong! Unable to Update Retell LLM");
}
}
}
class GetPhoneNumberDetailsCommand implements Command<TGetPhoneNumberSchema> {
constructor(private phoneNumber: string) {}
async execute(): Promise<TGetPhoneNumberSchema> {
try {
const phoneNumberDetails = await fetcher(`/get-phone-number/${this.phoneNumber}`).then(
ZGetPhoneNumberSchema.parse
);
return phoneNumberDetails;
} catch (err) {
log.error("Unable to Get Phone number", safeStringify(err));
throw new Error(
"Something went wrong! Unable to Get Phone number. Please only use the phone number assigned to you."
);
}
}
}
export const validatePhoneNumber = (phoneNumber: string) => {
const command = new GetPhoneNumberDetailsCommand(phoneNumber);
return command.execute();
};
class CreateRetellPhoneCallCommand implements Command<TCreatePhoneSchema> {
constructor(private props: initProps, private numberToCall: string) {}
async execute(): Promise<TCreatePhoneSchema> {
try {
const createPhoneCallRes = await fetcher("/create-phone-call", {
method: "POST",
body: JSON.stringify({
from_number: this.props.yourPhoneNumber,
to_number: this.numberToCall,
retell_llm_dynamic_variables: {
name: this.props.dynamicVariables.guestName,
company: this.props.dynamicVariables.guestCompany,
email: this.props.dynamicVariables.guestEmail,
},
}),
}).then(ZCreatePhoneSchema.parse);
return createPhoneCallRes;
} catch (err) {
log.error("Unable to Get Phone number", safeStringify(err));
throw new Error("Something went wrong! Unable to Get Phone number");
}
}
}
export class RetellAIService {
private props: initProps;
constructor(props: initProps) {
this.props = props;
}
async createRetellLLMAndUpdateWebsocketUrl(): Promise<TCreateRetellLLMSchema> {
const command = new CreateRetellLLMCommand(this.props);
return command.execute();
}
async getRetellLLM(llmId: string): Promise<TGetRetellLLMSchema> {
const command = new GetRetellLLMCommand(llmId);
return command.execute();
}
async updatedRetellLLMAndUpdateWebsocketUrl(llmId: string): Promise<TGetRetellLLMSchema> {
const command = new UpdateRetellLLMCommand(this.props, llmId);
return command.execute();
}
async getPhoneNumberDetails(): Promise<TGetPhoneNumberSchema> {
const command = new GetPhoneNumberDetailsCommand(this.props.yourPhoneNumber);
return command.execute();
}
async createRetellPhoneCall(numberToCall: string): Promise<TCreatePhoneSchema> {
const command = new CreateRetellPhoneCallCommand(this.props, numberToCall);
return command.execute();
}
}
@@ -0,0 +1,54 @@
import type { TemplateType, Fields } from "./zod-utils";
import { fieldNameEnum } from "./zod-utils";
export const TEMPLATES_FIELDS: Record<TemplateType, Fields> = {
CHECK_IN_APPOINTMENT: [
{
type: "text",
name: fieldNameEnum.enum.schedulerName,
required: true,
defaultLabel: "scheduler_name",
placeholder: "Enter your name",
},
],
CUSTOM_TEMPLATE: [
{
type: "textarea",
name: fieldNameEnum.enum.generalPrompt,
required: true,
defaultLabel: "general_prompt",
placeholder: "Enter your prompt",
},
{
type: "text",
name: fieldNameEnum.enum.beginMessage,
required: true,
defaultLabel: "begin_message",
placeholder: "begin_message",
},
{
type: "text",
name: fieldNameEnum.enum.guestName,
required: false,
defaultLabel: "guest_name",
placeholder: "guest_name",
variableName: "name",
},
{
type: "email",
name: fieldNameEnum.enum.guestEmail,
required: false,
defaultLabel: "guest_email",
placeholder: "guest_email",
variableName: "email",
},
{
type: "text",
name: fieldNameEnum.enum.guestCompany,
required: false,
defaultLabel: "guest_company",
placeholder: "guest_company",
variableName: "company",
},
],
};
@@ -0,0 +1,171 @@
import { isValidPhoneNumber } from "libphonenumber-js";
import z from "zod";
export const templateTypeEnum = z.enum(["CHECK_IN_APPOINTMENT", "CUSTOM_TEMPLATE"]);
const requiredFields = z.object({
yourPhoneNumber: z.string().refine((val) => isValidPhoneNumber(val)),
numberToCall: z.string().refine((val) => isValidPhoneNumber(val)),
calApiKey: z.string().trim().min(1, {
message: "Please enter CAL API Key",
}),
eventTypeId: z.number(),
enabled: z.boolean().default(false),
templateType: templateTypeEnum,
});
export const createPhoneCallSchema = requiredFields.merge(
z.object({
schedulerName: z.string().min(1).optional().nullable(),
guestName: z
.string()
.optional()
.transform((val) => {
return !!val ? val : undefined;
}),
guestEmail: z
.string()
.optional()
.transform((val) => {
return !!val ? val : undefined;
}),
guestCompany: z
.string()
.optional()
.transform((val) => {
return !!val ? val : undefined;
}),
beginMessage: z.string().optional(),
generalPrompt: z.string().optional(),
})
);
export type TCreatePhoneCallSchema = z.infer<typeof createPhoneCallSchema>;
export const ZGetPhoneNumberSchema = z
.object({
agent_id: z.string(),
inbound_agent_id: z.string(),
outbound_agent_id: z.string(),
error_message: z.string().optional(),
})
.passthrough();
export type TGetPhoneNumberSchema = z.infer<typeof ZGetPhoneNumberSchema>;
export const ZCreatePhoneSchema = z
.object({
call_id: z.string(),
agent_id: z.string(),
})
.passthrough();
export type TCreatePhoneSchema = z.infer<typeof ZCreatePhoneSchema>;
export const fieldSchemaMap = {
CHECK_IN_APPOINTMENT: requiredFields.merge(
z.object({
schedulerName: z.string().min(1),
})
),
CUSTOM_TEMPLATE: createPhoneCallSchema.omit({ generalPrompt: true }).merge(
z.object({
generalPrompt: z.string(),
})
),
};
export type TemplateType = z.infer<typeof templateTypeEnum>;
const fieldTypeEnum = z.enum([
"name",
"text",
"textarea",
"number",
"email",
"phone",
"address",
"multiemail",
"select",
"multiselect",
"checkbox",
"radio",
"radioInput",
"boolean",
]);
export const fieldNameEnum = z.enum([
"schedulerName",
"generalPrompt",
"guestName",
"guestEmail",
"guestCompany",
"beginMessage",
]);
export type FieldType = z.infer<typeof fieldTypeEnum>;
const FieldSchema = z.object({
type: fieldTypeEnum,
name: fieldNameEnum,
required: z.boolean(),
defaultLabel: z.string(),
placeholder: z.string(),
variableName: z.string().optional(),
});
const FieldsSchema = z.array(FieldSchema);
export type Fields = z.infer<typeof FieldsSchema>;
export const ZCreateRetellLLMSchema = z
.object({
llm_id: z.string(),
llm_websocket_url: z.string(),
})
.passthrough();
export type TCreateRetellLLMSchema = z.infer<typeof ZCreateRetellLLMSchema>;
export const ZGetRetellLLMSchema = z
.object({
general_prompt: z.string(),
begin_message: z.string().nullable(),
llm_id: z.string(),
llm_websocket_url: z.string(),
general_tools: z.array(
z
.object({
name: z.string(),
type: z.string(),
cal_api_key: z.string().optional(),
event_type_id: z.number().optional(),
timezone: z.string().optional(),
})
.passthrough()
),
states: z
.array(
z
.object({
name: z.string(),
tools: z.array(
z
.object({
name: z.string(),
type: z.string(),
cal_api_key: z.string().optional(),
event_type_id: z.number().optional(),
timezone: z.string().optional(),
})
.passthrough()
),
})
.passthrough()
)
.nullable()
.optional(),
})
.passthrough();
export type TGetRetellLLMSchema = z.infer<typeof ZGetRetellLLMSchema>;
+5 -3
View File
@@ -65,9 +65,11 @@ export type FormValues = {
beginMessage: string;
yourPhoneNumber: string;
numberToCall: string;
guestName: string;
guestEmail: string;
guestCompany: string;
guestName?: string;
guestEmail?: string;
guestCompany?: string;
templateType: string;
schedulerName?: string;
};
customInputs: CustomInputParsed[];
schedule: number | null;
@@ -53,9 +53,11 @@ export type Event = {
beginMessage: string | null;
yourPhoneNumber: string;
numberToCall: string;
guestName: string;
guestEmail: string;
guestCompany: string;
guestName?: string;
guestEmail?: string;
guestCompany?: string;
templateType: string;
schedulerName?: string;
};
schedulingType: string;
length: number;
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "AIPhoneCallConfiguration" ADD COLUMN "schedulerName" TEXT,
ADD COLUMN "templateType" TEXT NOT NULL DEFAULT 'CUSTOM_TEMPLATE',
ALTER COLUMN "generalPrompt" DROP NOT NULL,
ALTER COLUMN "guestName" DROP NOT NULL;
+4 -2
View File
@@ -953,10 +953,12 @@ model AIPhoneCallConfiguration {
id Int @id @default(autoincrement())
eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
eventTypeId Int
generalPrompt String
templateType String @default("CUSTOM_TEMPLATE")
schedulerName String?
generalPrompt String?
yourPhoneNumber String
numberToCall String
guestName String
guestName String?
guestEmail String?
guestCompany String?
enabled Boolean @default(false)
-62
View File
@@ -1,6 +1,5 @@
import type { Prisma } from "@prisma/client";
import type { UnitTypeLongPlural } from "dayjs";
import { isValidPhoneNumber } from "libphonenumber-js";
import type { TFunction } from "next-i18next";
import z, { ZodNullable, ZodObject, ZodOptional } from "zod";
import type {
@@ -710,64 +709,3 @@ export const bookingSeatDataSchema = z.object({
description: z.string().optional(),
responses: bookingResponses,
});
export const AIPhoneSettingSchema = z.object({
yourPhoneNumber: z.string().refine((val) => isValidPhoneNumber(val)),
numberToCall: z.string().refine((val) => isValidPhoneNumber(val)),
guestName: z.string().trim().min(1, {
message: "Please enter Guest Name",
}),
guestEmail: z.string().email().nullable().optional(),
guestCompany: z.string().nullable().optional(),
generalPrompt: z.string().trim().min(1, {
message: "Please enter prompt",
}),
beginMessage: z.string().nullable(),
eventTypeId: z.number(),
calApiKey: z.string().trim().min(1, {
message: "Please enter CAL API Key",
}),
});
export const getRetellLLMSchema = z
.object({
general_prompt: z.string(),
begin_message: z.string().nullable(),
llm_id: z.string(),
llm_websocket_url: z.string(),
general_tools: z.array(
z
.object({
name: z.string(),
type: z.string(),
cal_api_key: z.string().optional(),
event_type_id: z.number().optional(),
timezone: z.string().optional(),
})
.passthrough()
),
states: z
.array(
z
.object({
name: z.string(),
tools: z.array(
z
.object({
name: z.string(),
type: z.string(),
cal_api_key: z.string().optional(),
event_type_id: z.number().optional(),
timezone: z.string().optional(),
})
.passthrough()
),
})
.passthrough()
)
.nullable()
.optional(),
})
.passthrough();
export type TGetRetellLLMSchema = z.infer<typeof getRetellLLMSchema>;
@@ -1,5 +1,6 @@
import { z } from "zod";
import { templateTypeEnum } from "@calcom/features/ee/cal-ai-phone/zod-utils";
import { _DestinationCalendarModel, _EventTypeModel } from "@calcom/prisma/zod";
import { customInputSchema, EventTypeMetaDataSchema, stringOrNumber } from "@calcom/prisma/zod-utils";
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
@@ -16,9 +17,14 @@ export const EventTypeUpdateInput = _EventTypeModel
beginMessage: z.string().nullable(),
yourPhoneNumber: z.string().default(""),
numberToCall: z.string().default(""),
guestName: z.string().default(""),
guestName: z
.string()
.nullable()
.optional()
.transform((val) => (!!val ? val : undefined)),
guestEmail: z.string().nullable().default(null),
guestCompany: z.string().nullable().default(null),
templateType: templateTypeEnum,
})
.optional(),
calAiPhoneScript: z.string().optional(),
@@ -1,5 +1,5 @@
import { createPhoneCallSchema } from "@calcom/features/ee/cal-ai-phone/zod-utils";
import { ZVerifyCodeInputSchema } from "@calcom/prisma/zod-utils";
import { AIPhoneSettingSchema } from "@calcom/prisma/zod-utils";
import authedProcedure, {
authedAdminProcedure,
@@ -161,7 +161,7 @@ export const viewerOrganizationsRouter = router({
const handler = await importHandler(namespaced("adminDelete"), () => import("./adminDelete.handler"));
return handler(opts);
}),
createPhoneCall: authedProcedure.input(AIPhoneSettingSchema).mutation(async (opts) => {
createPhoneCall: authedProcedure.input(createPhoneCallSchema).mutation(async (opts) => {
const handler = await importHandler(
namespaced("createPhoneCall"),
() => import("./createPhoneCall.handler")
@@ -1,12 +1,13 @@
import { z } from "zod";
import type { z } from "zod";
import { PROMPT_TEMPLATES } from "@calcom/features/ee/cal-ai-phone/promptTemplates";
import { RetellAIService, validatePhoneNumber } from "@calcom/features/ee/cal-ai-phone/retellAIService";
import type { createPhoneCallSchema } from "@calcom/features/ee/cal-ai-phone/zod-utils";
import { templateTypeEnum } from "@calcom/features/ee/cal-ai-phone/zod-utils";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { WEBAPP_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { fetcher } from "@calcom/lib/retellAIFetcher";
import type { PrismaClient } from "@calcom/prisma";
import { getRetellLLMSchema } from "@calcom/prisma/zod-utils";
import type { AIPhoneSettingSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@calcom/trpc/server";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
type CreatePhoneCallProps = {
@@ -14,35 +15,21 @@ type CreatePhoneCallProps = {
user: NonNullable<TrpcSessionUser>;
prisma: PrismaClient;
};
input: z.infer<typeof AIPhoneSettingSchema>;
input: z.infer<typeof createPhoneCallSchema>;
};
const createRetellLLMSchema = z
.object({
llm_id: z.string(),
llm_websocket_url: z.string(),
})
.passthrough();
const createPhoneSchema = z
.object({
call_id: z.string(),
agent_id: z.string(),
})
.passthrough();
const getPhoneNumberSchema = z
.object({
agent_id: z.string(),
})
.passthrough();
const createPhoneCallHandler = async ({ input, ctx }: CreatePhoneCallProps) => {
if (!!!ctx.user.profile.organization) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: `createPhoneCall:${ctx.user.id}`,
});
await validatePhoneNumber(input.yourPhoneNumber);
const {
yourPhoneNumber,
numberToCall,
@@ -51,69 +38,66 @@ const createPhoneCallHandler = async ({ input, ctx }: CreatePhoneCallProps) => {
guestCompany,
eventTypeId,
beginMessage,
generalPrompt,
calApiKey,
templateType,
schedulerName,
generalPrompt: userCustomPrompt,
} = input;
const generalPrompt =
templateType === templateTypeEnum.enum.CUSTOM_TEMPLATE
? userCustomPrompt
: PROMPT_TEMPLATES[templateType]?.generalPrompt;
const retellAI = new RetellAIService({
templateType,
generalPrompt: generalPrompt ?? "",
yourPhoneNumber,
loggedInUserTimeZone: ctx.user.timeZone,
eventTypeId,
calApiKey,
dynamicVariables: {
guestName,
guestEmail,
guestCompany,
schedulerName,
},
});
const aiPhoneCallConfig = await ctx.prisma.aIPhoneCallConfiguration.upsert({
where: {
eventTypeId,
},
update: {
beginMessage,
generalPrompt,
enabled: true,
guestName,
guestEmail,
guestCompany,
numberToCall,
yourPhoneNumber,
schedulerName,
templateType,
generalPrompt,
},
create: {
eventTypeId,
beginMessage,
generalPrompt,
enabled: true,
guestName,
guestEmail,
guestCompany,
numberToCall,
yourPhoneNumber,
schedulerName,
templateType,
generalPrompt,
},
});
let llmWebSocketUrlToBeUpdated = null;
// If no retell LLM is associated with the event type, create one
if (!aiPhoneCallConfig.llmId) {
const createdRetellLLM = await fetcher("/create-retell-llm", {
method: "POST",
body: JSON.stringify({
general_prompt: generalPrompt,
begin_message: beginMessage,
inbound_dynamic_variables_webhook_url: `${WEBAPP_URL}/api/get-inbound-dynamic-variables`,
general_tools: [
{
type: "end_call",
name: "end_call",
description: "Hang up the call, triggered only after appointment successfully scheduled.",
},
{
type: "check_availability_cal",
name: "check_availability",
cal_api_key: calApiKey,
event_type_id: eventTypeId,
timezone: ctx.user.timeZone,
},
{
type: "book_appointment_cal",
name: "book_appointment",
cal_api_key: calApiKey,
event_type_id: eventTypeId,
timezone: ctx.user.timeZone,
},
],
}),
}).then(createRetellLLMSchema.parse);
const createdRetellLLM = await retellAI.createRetellLLMAndUpdateWebsocketUrl();
await ctx.prisma.aIPhoneCallConfiguration.update({
where: {
@@ -123,54 +107,18 @@ const createPhoneCallHandler = async ({ input, ctx }: CreatePhoneCallProps) => {
llmId: createdRetellLLM.llm_id,
},
});
llmWebSocketUrlToBeUpdated = createdRetellLLM.llm_websocket_url;
} else {
const retellLLM = await fetcher(`/get-retell-llm/${aiPhoneCallConfig.llmId}`).then(
getRetellLLMSchema.parse
);
if (retellLLM.general_prompt !== generalPrompt || retellLLM.begin_message !== beginMessage) {
const updatedRetellLLM = await fetcher(`/update-retell-llm/${aiPhoneCallConfig.llmId}`, {
method: "PATCH",
body: JSON.stringify({
general_prompt: generalPrompt,
begin_message: beginMessage,
inbound_dynamic_variables_webhook_url: `${WEBAPP_URL}/api/get-inbound-dynamic-variables`,
}),
}).then(getRetellLLMSchema.parse);
const retellLLM = await retellAI.getRetellLLM(aiPhoneCallConfig.llmId);
const doWeNeedToUpdateLLM =
retellLLM.general_prompt !== generalPrompt || retellLLM.begin_message !== beginMessage;
if (doWeNeedToUpdateLLM) {
const updatedRetellLLM = await retellAI.updatedRetellLLMAndUpdateWebsocketUrl(aiPhoneCallConfig.llmId);
logger.debug("updated Retell LLM", updatedRetellLLM);
llmWebSocketUrlToBeUpdated = updatedRetellLLM.llm_websocket_url;
}
}
if (llmWebSocketUrlToBeUpdated) {
const getPhoneNumberDetails = await fetcher(`/get-phone-number/${yourPhoneNumber}`).then(
getPhoneNumberSchema.parse
);
const updated = await fetcher(`/update-agent/${getPhoneNumberDetails.agent_id}`, {
method: "PATCH",
body: JSON.stringify({
llm_websocket_url: llmWebSocketUrlToBeUpdated,
}),
});
logger.debug("updated Retell Agent", updated);
}
// Create Phone Call
const createPhoneCallRes = await fetcher("/create-phone-call", {
method: "POST",
body: JSON.stringify({
from_number: yourPhoneNumber,
to_number: numberToCall,
retell_llm_dynamic_variables: { name: guestName, company: guestCompany, email: guestEmail },
}),
}).then(createPhoneSchema.parse);
const createPhoneCallRes = await retellAI.createRetellPhoneCall(numberToCall);
logger.debug("Create Call Response", createPhoneCallRes);
return createPhoneCallRes;