diff --git a/apps/web/components/eventtype/AIEventController.tsx b/apps/web/components/eventtype/AIEventController.tsx index 783161abb3..9d9b78c1bf 100644 --- a/apps/web/components/eventtype/AIEventController.tsx +++ b/apps/web/components/eventtype/AIEventController.tsx @@ -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 ( +
+ +

{t(message || "invalid_input")}

+
+ ); +}; + +const TemplateFields = () => { + const { t } = useLocale(); + const formMethods = useFormContext(); + const { control, watch } = formMethods; + + const templateType = watch("aiPhoneCallConfig.templateType"); + const fields = TEMPLATES_FIELDS[templateType as TemplateType]; + + return ( +
+ {fields?.map((field) => ( +
+ { + const { variableName, ...restField } = field; + const variableInfo = !!variableName ? `: ${t("variable")} {{${variableName}}}` : ""; + return ( +
+ { + onChange(val); + }} + /> + {error?.message && } +
+ ); + }} + /> +
+ ))} +
+ ); +}; + const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => { const { t } = useLocale(); const formMethods = useFormContext(); + 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 ( - // - // ); - // }; - - // 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 }) => { { + render={({ field: { onChange, value, name }, fieldState: { error } }) => { return ( - { - onChange(val); - }} - /> +
+ { + onChange(val); + }} + /> + {error?.message && } +
); }} /> + <> + { + formMethods.setValue("aiPhoneCallConfig.templateType", val, { shouldDirty: true }); + }}> +
+
+ + + + +
+
+ + + + +
+
+
+ + { + render={({ field: { onChange, value, name }, fieldState: { error } }) => { return ( - { - onChange(val); - }} - /> +
+ { + onChange(val); + }} + /> + {error?.message && } +
); }} /> + + - - - - - - { /> - { - formMethods.setValue("aiPhoneCallConfig.generalPrompt", e.target.value, { shouldDirty: true }); - }} - /> - - { - formMethods.setValue("aiPhoneCallConfig.beginMessage", e.target.value, { shouldDirty: true }); - }} - /> - - } - /> - !isOpen && setCreateModalOpen(false)}> - -
- -
-
-
- - */} ); }; diff --git a/apps/web/modules/event-types/views/event-types-single-view.tsx b/apps/web/modules/event-types/views/event-types-single-view.tsx index 1110a81f5e..dd6bb61b34 100644 --- a/apps/web/modules/event-types/views/event-types-single-view.tsx +++ b/apps/web/modules/event-types/views/event-types-single-view.tsx @@ -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]); diff --git a/apps/web/pages/api/get-inbound-dynamic-variables.ts b/apps/web/pages/api/get-inbound-dynamic-variables.ts index 0d238aa37a..e7bc8ea994 100644 --- a/apps/web/pages/api/get-inbound-dynamic-variables.ts +++ b/apps/web/pages/api/get-inbound-dynamic-variables.ts @@ -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); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index af842cd995..0c16386739 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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 {{email}}. Click the link in the email to verify this address.", "send_event_details_to": "Send event details to", diff --git a/packages/features/ee/cal-ai-phone/getTemplateFieldsSchema.ts b/packages/features/ee/cal-ai-phone/getTemplateFieldsSchema.ts new file mode 100644 index 0000000000..d504fd6b80 --- /dev/null +++ b/packages/features/ee/cal-ai-phone/getTemplateFieldsSchema.ts @@ -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]; +}; diff --git a/packages/features/ee/cal-ai-phone/promptTemplates.ts b/packages/features/ee/cal-ai-phone/promptTemplates.ts new file mode 100644 index 0000000000..3560abdcda --- /dev/null +++ b/packages/features/ee/cal-ai-phone/promptTemplates.ts @@ -0,0 +1,62 @@ +import { templateTypeEnum } from "./zod-utils"; +import type { TemplateType } from "./zod-utils"; + +export const PROMPT_TEMPLATES: Partial> = { + [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?"; diff --git a/packages/features/ee/cal-ai-phone/retellAIService.ts b/packages/features/ee/cal-ai-phone/retellAIService.ts new file mode 100644 index 0000000000..ed911c6949 --- /dev/null +++ b/packages/features/ee/cal-ai-phone/retellAIService.ts @@ -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 => { + 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 { + execute(): Promise; +} + +class CreateRetellLLMCommand implements Command { + constructor(private props: initProps) {} + + async execute(): Promise { + 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 { + constructor(private llmId: string) {} + + async execute(): Promise { + 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 { + constructor(private props: initProps, private llmId: string) {} + + async execute(): Promise { + 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 { + constructor(private phoneNumber: string) {} + + async execute(): Promise { + 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 { + constructor(private props: initProps, private numberToCall: string) {} + + async execute(): Promise { + 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 { + const command = new CreateRetellLLMCommand(this.props); + return command.execute(); + } + + async getRetellLLM(llmId: string): Promise { + const command = new GetRetellLLMCommand(llmId); + return command.execute(); + } + + async updatedRetellLLMAndUpdateWebsocketUrl(llmId: string): Promise { + const command = new UpdateRetellLLMCommand(this.props, llmId); + return command.execute(); + } + + async getPhoneNumberDetails(): Promise { + const command = new GetPhoneNumberDetailsCommand(this.props.yourPhoneNumber); + return command.execute(); + } + + async createRetellPhoneCall(numberToCall: string): Promise { + const command = new CreateRetellPhoneCallCommand(this.props, numberToCall); + return command.execute(); + } +} diff --git a/packages/features/ee/cal-ai-phone/template-fields-map.ts b/packages/features/ee/cal-ai-phone/template-fields-map.ts new file mode 100644 index 0000000000..0790a42451 --- /dev/null +++ b/packages/features/ee/cal-ai-phone/template-fields-map.ts @@ -0,0 +1,54 @@ +import type { TemplateType, Fields } from "./zod-utils"; +import { fieldNameEnum } from "./zod-utils"; + +export const TEMPLATES_FIELDS: Record = { + 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", + }, + ], +}; diff --git a/packages/features/ee/cal-ai-phone/zod-utils.ts b/packages/features/ee/cal-ai-phone/zod-utils.ts new file mode 100644 index 0000000000..994484f752 --- /dev/null +++ b/packages/features/ee/cal-ai-phone/zod-utils.ts @@ -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; + +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; + +export const ZCreatePhoneSchema = z + .object({ + call_id: z.string(), + agent_id: z.string(), + }) + .passthrough(); + +export type TCreatePhoneSchema = z.infer; + +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; + +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; + +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; + +export const ZCreateRetellLLMSchema = z + .object({ + llm_id: z.string(), + llm_websocket_url: z.string(), + }) + .passthrough(); + +export type TCreateRetellLLMSchema = z.infer; + +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; diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts index 3760621c3a..393dbbeb47 100644 --- a/packages/features/eventtypes/lib/types.ts +++ b/packages/features/eventtypes/lib/types.ts @@ -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; diff --git a/packages/platform/sdk/src/endpoints/events/types.ts b/packages/platform/sdk/src/endpoints/events/types.ts index af7541f4d4..c185c61474 100644 --- a/packages/platform/sdk/src/endpoints/events/types.ts +++ b/packages/platform/sdk/src/endpoints/events/types.ts @@ -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; diff --git a/packages/prisma/migrations/20240610084425_add_cal_ai_template/migration.sql b/packages/prisma/migrations/20240610084425_add_cal_ai_template/migration.sql new file mode 100644 index 0000000000..75e14bc2e8 --- /dev/null +++ b/packages/prisma/migrations/20240610084425_add_cal_ai_template/migration.sql @@ -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; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 51db6dabe0..416bc1e48d 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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) diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index cd575b2544..2c21c0d55a 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -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; diff --git a/packages/trpc/server/routers/viewer/eventTypes/types.ts b/packages/trpc/server/routers/viewer/eventTypes/types.ts index 65c21b3292..6062b1cc25 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/types.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/types.ts @@ -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(), diff --git a/packages/trpc/server/routers/viewer/organizations/_router.tsx b/packages/trpc/server/routers/viewer/organizations/_router.tsx index aa0aede5fe..4392508ff3 100644 --- a/packages/trpc/server/routers/viewer/organizations/_router.tsx +++ b/packages/trpc/server/routers/viewer/organizations/_router.tsx @@ -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") diff --git a/packages/trpc/server/routers/viewer/organizations/createPhoneCall.handler.ts b/packages/trpc/server/routers/viewer/organizations/createPhoneCall.handler.ts index a4ed536f0b..9bec06ad2c 100644 --- a/packages/trpc/server/routers/viewer/organizations/createPhoneCall.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/createPhoneCall.handler.ts @@ -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; prisma: PrismaClient; }; - input: z.infer; + input: z.infer; }; -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;