feat: cal.ai enterprise phone calls (#14100)
* added empty layout * init * yarn lock due to package changes * added retell ai to .env * removed ton of glue code * nit * Discard changes to package.json * Upgrades lucide-react * minor UI fixes * nit * nit * feat: save progress * feat: v1 * fix: type error * feat: change schema * fix: type error and testr * chore: update agent * chore: change default prompt * feat: feedback and improvements * fix: type error * fix: type error * hidden for now while in trial * added i18n and removed some comments * feat: add cal api key * fix: type error --------- Co-authored-by: Omar López <zomars@me.com> Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Co-authored-by: Udit Takkar <udit222001@gmail.com> Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
This commit is contained in:
co-authored by
Omar López
Udit Takkar
Udit Takkar
Joe Au-Yeung
parent
eec76ecfe2
commit
1025238eee
+6
-1
@@ -345,4 +345,9 @@ SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN=1
|
||||
NEXT_PUBLIC_API_V2_URL="http://localhost:5555/api/v2"
|
||||
|
||||
# Ratelimiting via unkey
|
||||
UNKEY_ROOT_KEY=
|
||||
UNKEY_ROOT_KEY=
|
||||
|
||||
|
||||
# Used for Cal.ai Enterprise Voice AI Agents
|
||||
# https://retellai.com
|
||||
RETELL_AI_KEY=
|
||||
|
||||
@@ -122,7 +122,7 @@ export const InstallAppButtonChild = ({
|
||||
data-testid={team.isUser ? "install-app-button-personal" : "anything else"}
|
||||
key={team.id}
|
||||
disabled={isInstalled}
|
||||
StartIcon={(props) => (
|
||||
StartIcon={(props: { className?: string }) => (
|
||||
<Avatar
|
||||
alt={team.logo || ""}
|
||||
imageSrc={team.logo || `${WEBAPP_URL}/${team.logo}/avatar.png`} // if no image, use default avatar
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import type { EventTypeSetup } from "pages/event-types/[type]";
|
||||
import { useState } from "react";
|
||||
import { useFormContext, Controller } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
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,
|
||||
Label,
|
||||
EmptyScreen,
|
||||
SettingsToggle,
|
||||
Divider,
|
||||
TextField,
|
||||
TextAreaField,
|
||||
PhoneInput,
|
||||
showToast,
|
||||
} from "@calcom/ui";
|
||||
import { Sparkles } from "@calcom/ui/components/icon";
|
||||
|
||||
type AIEventControllerProps = {
|
||||
eventType: EventTypeSetup;
|
||||
isTeamEvent: boolean;
|
||||
};
|
||||
|
||||
export default function AIEventController({ eventType, isTeamEvent }: AIEventControllerProps) {
|
||||
const { t } = useLocale();
|
||||
const session = useSession();
|
||||
const [aiEventState, setAIEventState] = useState<boolean>(eventType?.aiPhoneCallConfig?.enabled ?? false);
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
|
||||
const isOrg = !!session.data?.user?.org?.id;
|
||||
|
||||
if (session.status === "loading") return <></>;
|
||||
|
||||
return (
|
||||
<LicenseRequired>
|
||||
<div className="block items-start sm:flex">
|
||||
{!isOrg || !isTeamEvent ? (
|
||||
<EmptyScreen
|
||||
headline={t("Cal.ai")}
|
||||
Icon={Sparkles}
|
||||
description={t("upgrade_to_cal_ai_phone_number_description")}
|
||||
buttonRaw={<Button href="/enterprise">{t("upgrade")}</Button>}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
|
||||
aiEventState && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("Cal.ai")}
|
||||
description={t("use_cal_ai_to_make_call_description")}
|
||||
checked={aiEventState}
|
||||
data-testid="instant-event-check"
|
||||
onCheckedChange={(e) => {
|
||||
if (!e) {
|
||||
formMethods.setValue("aiPhoneCallConfig.enabled", false, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
setAIEventState(false);
|
||||
} else {
|
||||
formMethods.setValue("aiPhoneCallConfig.enabled", true, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
setAIEventState(true);
|
||||
}
|
||||
}}>
|
||||
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
|
||||
{aiEventState && <AISettings eventType={eventType} />}
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</LicenseRequired>
|
||||
);
|
||||
}
|
||||
|
||||
const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const [calApiKey, setCalApiKey] = useState("");
|
||||
|
||||
const createCallMutation = trpc.viewer.organizations.createPhoneCall.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (!!data?.call_id) {
|
||||
showToast("Phone Call Created successfully", "success");
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(t("something_went_wrong"), "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 data = await AIPhoneSettingSchema.parseAsync({
|
||||
...values,
|
||||
eventTypeId: eventType.id,
|
||||
calApiKey,
|
||||
});
|
||||
|
||||
createCallMutation.mutate(data);
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
const fieldName = err.issues?.[0]?.path?.[0];
|
||||
const message = err.issues?.[0]?.message;
|
||||
showToast(`Error on ${fieldName}: ${message} `, "error");
|
||||
} else {
|
||||
showToast(t("something_went_wrong"), "error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-4">
|
||||
<>
|
||||
<Label>{t("your_phone_number")}</Label>
|
||||
<Controller
|
||||
name="aiPhoneCallConfig.yourPhoneNumber"
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<PhoneInput
|
||||
required
|
||||
placeholder={t("your_phone_number")}
|
||||
id="aiPhoneCallConfig.yourPhoneNumber"
|
||||
name="aiPhoneCallConfig.yourPhoneNumber"
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
onChange(val);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Label>{t("number_to_call")}</Label>
|
||||
<Controller
|
||||
name="aiPhoneCallConfig.numberToCall"
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<PhoneInput
|
||||
required
|
||||
placeholder={t("phone_number")}
|
||||
id="aiPhoneCallConfig.numberToCall"
|
||||
name="aiPhoneCallConfig.numberToCall"
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
onChange(val);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
</>
|
||||
|
||||
<TextField
|
||||
type="text"
|
||||
hint="Variable: {name}"
|
||||
label={t("guest_name")}
|
||||
placeholder="Jane Doe"
|
||||
{...formMethods.register("aiPhoneCallConfig.guestName")}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
type="text"
|
||||
hint="For eg:- cal_live_0123.."
|
||||
label={t("provide_api_key")}
|
||||
name="calApiKey"
|
||||
placeholder="Cal API Key"
|
||||
value={calApiKey}
|
||||
onChange={(e) => {
|
||||
setCalApiKey(e.target.value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<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}
|
||||
loading={createCallMutation.isPending}
|
||||
onClick={handleSubmit}>
|
||||
{t("make_a_call")}
|
||||
</Button>
|
||||
|
||||
{/* TODO:<small className="block opacity-60">
|
||||
Want to automate outgoing phone calls? Read our{" "}
|
||||
<Link className="underline" href="https://cal.com/docs">
|
||||
API docs
|
||||
</Link>{" "}
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||
|
||||
import AIEventController from "./AIEventController";
|
||||
|
||||
export const EventAITab = ({
|
||||
eventType,
|
||||
isTeamEvent,
|
||||
}: Pick<EventTypeSetupProps, "eventType"> & { isTeamEvent: boolean }) => {
|
||||
return <AIEventController eventType={eventType} isTeamEvent={isTeamEvent} />;
|
||||
};
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
Repeat,
|
||||
Grid,
|
||||
Zap,
|
||||
Sparkles,
|
||||
Users,
|
||||
ExternalLink,
|
||||
Code,
|
||||
@@ -283,6 +284,15 @@ function EventTypeSingleLayout({
|
||||
info: `${activeWebhooksNumber} ${t("active")}`,
|
||||
});
|
||||
}
|
||||
const hidden = true; // hidden while in alpha trial. you can access it with tabName=ai
|
||||
if (team && hidden) {
|
||||
navigation.push({
|
||||
name: "Cal.ai",
|
||||
href: `/event-types/${eventType.id}?tabName=ai`,
|
||||
icon: Sparkles,
|
||||
info: "cal_ai_event_tab_description", // todo `cal_ai_event_tab_description`,
|
||||
});
|
||||
}
|
||||
return navigation;
|
||||
}, [
|
||||
t,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React from "react";
|
||||
|
||||
import type { LucideIcon } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { SVGComponent } from "@lib/types/SVGComponent";
|
||||
|
||||
interface LinkIconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
Icon: SVGComponent;
|
||||
Icon: SVGComponent | LucideIcon;
|
||||
}
|
||||
|
||||
export default function LinkIconButton(props: LinkIconButtonProps) {
|
||||
|
||||
@@ -34,6 +34,11 @@ 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.\n\n## Style Guardrails\nBe Concise: Respond succinctly, addressing one topic at most.\nEmbrace Variety: Use diverse language and rephrasing to enhance clarity without repeating content.\nBe Conversational: Use everyday language, making the chat feel like talking to a friend.\nBe Proactive: Lead the conversation, often wrapping up with a question or next-step suggestion.\nAvoid multiple questions in a single response.\nGet clarity: If the user only partially answers a question, or if the answer is unclear, keep asking to get clarity.\nUse a colloquial way of referring to the date (like Friday, Jan 14th, or Tuesday, Jan 12th, 2024 at 8am).\nIf you are saying a time like 8:00 AM, just say 8 AM and emit the trailing zeros.\n\n## Response Guideline\nAdapt and Guess: Try to understand transcripts that may contain transcription errors. Avoid mentioning \"transcription error\" in the response.\nStay in Character: Keep conversations within your role'''s scope, guiding them back creatively without repeating.\nEnsure Fluid Dialogue: Respond in a role-appropriate, direct manner to maintain a smooth conversation flow.\n\n## Schedule Rule\nCurrent time is {{current_time}}. You only schedule time in current calendar year, you cannot schedule time that'''s in the past.\n\n## Task Steps\n1. I am here to learn more about your issue and help schedule an appointment with our support team.\n2. Ask for user name and email. Confirm the name and email with user by reading it back to user.\n3. Ask user for \"When would you want to meet with one of our representive\".\n4. Call function check_availability to check for availability in the user provided time range.\n - 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.\n - if availability does not exist, ask user to select another time range for the appointment, repeat this step 3.\n4. Confirm the date and time selected by user: \"Just to confirm, you want to book the appointment at ...\".\n6. Once confirmed, call function book_appointment to book the appointment.\n - if booking returned booking detail, it means booking is successful, proceed to step 7.\n - if booking returned error message, let user know why the booking was not successful, and maybe start over with step 3.\n7. Inform the user booking is successful, and ask if user have any questions. Answer them if there are any.\n8. 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)
|
||||
@@ -73,6 +78,8 @@ const EventWebhooksTab = dynamic(() =>
|
||||
import("@components/eventtype/EventWebhooksTab").then((mod) => mod.EventWebhooksTab)
|
||||
);
|
||||
|
||||
const EventAITab = dynamic(() => import("@components/eventtype/EventAITab").then((mod) => mod.EventAITab));
|
||||
|
||||
const ManagedEventTypeDialog = dynamic(() => import("@components/eventtype/ManagedEventDialog"));
|
||||
|
||||
export type Host = { isFixed: boolean; userId: number; priority: number };
|
||||
@@ -92,6 +99,7 @@ const querySchema = z.object({
|
||||
"advanced",
|
||||
"workflows",
|
||||
"webhooks",
|
||||
"ai",
|
||||
])
|
||||
.optional()
|
||||
.default("setup"),
|
||||
@@ -243,6 +251,14 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
})),
|
||||
seatsPerTimeSlotEnabled: eventType.seatsPerTimeSlot,
|
||||
assignAllTeamMembers: eventType.assignAllTeamMembers,
|
||||
aiPhoneCallConfig: {
|
||||
generalPrompt: eventType.aiPhoneCallConfig?.generalPrompt ?? DEFAULT_PROMPT_VALUE,
|
||||
enabled: eventType.aiPhoneCallConfig?.enabled,
|
||||
beginMessage: eventType.aiPhoneCallConfig?.beginMessage ?? DEFAULT_BEGIN_MESSAGE,
|
||||
guestName: eventType.aiPhoneCallConfig?.guestName,
|
||||
yourPhoneNumber: eventType.aiPhoneCallConfig?.yourPhoneNumber,
|
||||
numberToCall: eventType.aiPhoneCallConfig?.numberToCall,
|
||||
},
|
||||
};
|
||||
}, [eventType, periodDates, metadata]);
|
||||
const formMethods = useForm<FormValues>({
|
||||
@@ -375,6 +391,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
/>
|
||||
),
|
||||
webhooks: <EventWebhooksTab eventType={eventType} />,
|
||||
ai: <EventAITab eventType={eventType} isTeamEvent={!!team} />,
|
||||
} as const;
|
||||
const isObject = <T,>(value: T): boolean => {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
@@ -81,6 +81,8 @@
|
||||
"payment": "Payment",
|
||||
"missing_card_fields": "Missing card fields",
|
||||
"pay_now": "Pay now",
|
||||
"general_prompt": "General Prompt",
|
||||
"begin_message":"Begin Message",
|
||||
"codebase_has_to_stay_opensource": "The codebase has to stay open source, whether it was modified or not",
|
||||
"cannot_repackage_codebase": "You can not repackage or sell the codebase",
|
||||
"acquire_license": "Acquire a commercial license to remove these terms by emailing",
|
||||
@@ -207,6 +209,7 @@
|
||||
"2fa_confirm_current_password": "Confirm your current password to get started.",
|
||||
"2fa_scan_image_or_use_code": "Scan the image below with the authenticator app on your phone or manually enter the text code instead.",
|
||||
"text": "Text",
|
||||
"your_phone_number":"Your Phone Number",
|
||||
"multiline_text": "Multiline Text",
|
||||
"number": "Number",
|
||||
"checkbox": "Checkbox",
|
||||
@@ -693,6 +696,7 @@
|
||||
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
|
||||
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
|
||||
"minutes": "Minutes",
|
||||
"use_cal_ai_to_make_call_description": "Use Cal.ai to get an AI powered phone number or make calls to guests.",
|
||||
"round_robin": "Round Robin",
|
||||
"round_robin_description": "Cycle meetings between multiple team members.",
|
||||
"managed_event": "Managed Event",
|
||||
@@ -1277,6 +1281,7 @@
|
||||
"upgrade": "Upgrade",
|
||||
"upgrade_to_access_recordings_title": "Upgrade to access recordings",
|
||||
"upgrade_to_access_recordings_description": "Recordings are only available as part of our teams plan. Upgrade to start recording your calls",
|
||||
"upgrade_to_cal_ai_phone_number_description":"Upgrade to Enterprise to generate an AI Agent phone number that can call guests to schedule calls",
|
||||
"recordings_are_part_of_the_teams_plan": "Recordings are part of the teams plan",
|
||||
"team_feature_teams": "This is a Team feature. Upgrade to Team to see your team's availability.",
|
||||
"team_feature_workflows": "This is a Team feature. Upgrade to Team to automate your event notifications and reminders with Workflows.",
|
||||
@@ -2249,6 +2254,8 @@
|
||||
"troubleshooter_tooltip": "Open the troubleshooter and figure out what is wrong with your schedule",
|
||||
"need_help": "Need help?",
|
||||
"troubleshooter": "Troubleshooter",
|
||||
"number_to_call": "Number to Call",
|
||||
"guest_name": "Guest Name",
|
||||
"please_install_a_calendar": "Please install a calendar",
|
||||
"instant_tab_title": "Instant Booking",
|
||||
"instant_event_tab_description": "Let people book immediately",
|
||||
@@ -2256,6 +2263,7 @@
|
||||
"dont_want_to_wait": "Don't want to wait?",
|
||||
"meeting_started": "Meeting Started",
|
||||
"pay_and_book": "Pay to book",
|
||||
"cal_ai_event_tab_description":"Let AI Agents book you",
|
||||
"booking_not_found_error": "Could not find booking",
|
||||
"booking_seats_full_error": "Booking seats are full",
|
||||
"missing_payment_credential_error": "Missing payment credentials",
|
||||
@@ -2330,5 +2338,6 @@
|
||||
"lock_org_users_eventtypes": "Lock individual event type creation",
|
||||
"lock_org_users_eventtypes_description": "Prevent members from creating their own event types.",
|
||||
"cookie_consent_checkbox": "I consent to our privacy policy and cookie usage",
|
||||
"make_a_call": "Make a Call",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"eslint": "^8.34.0",
|
||||
"lucide-react": "^0.171.0",
|
||||
"lucide-react": "^0.363.0",
|
||||
"turbo": "^1.10.1"
|
||||
},
|
||||
"resolutions": {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { SVGComponent } from "@calcom/types/SVGComponent";
|
||||
import { Alert, Button, Dialog, DialogClose, DialogContent, DialogFooter, Input } from "@calcom/ui";
|
||||
import { Link, Search } from "@calcom/ui/components/icon";
|
||||
import type { LucideIcon } from "@calcom/ui/components/icon";
|
||||
|
||||
interface ISearchDialog {
|
||||
isOpenDialog: boolean;
|
||||
@@ -85,7 +86,7 @@ export const SearchDialog = (props: ISearchDialog) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderTab = (Icon: SVGComponent, text: string, mode: Mode) => (
|
||||
const renderTab = (Icon: SVGComponent | LucideIcon, text: string, mode: Mode) => (
|
||||
<div
|
||||
className={classNames(
|
||||
"flex cursor-pointer items-center border-b-2 p-2 text-sm ",
|
||||
|
||||
@@ -6,6 +6,7 @@ import classNames from "@calcom/lib/classNames";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Clock, CheckSquare, RefreshCcw } from "@calcom/ui/components/icon";
|
||||
import type { LucideIcon } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { PublicEvent } from "../../types";
|
||||
import { EventDetailBlocks } from "../../types";
|
||||
@@ -34,7 +35,7 @@ type EventDetailCustomBlock = {
|
||||
type EventDetailsProps = EventDetailsPropsBase & (EventDetailDefaultBlock | EventDetailCustomBlock);
|
||||
|
||||
interface EventMetaProps {
|
||||
icon?: React.FC<{ className: string }> | string;
|
||||
icon?: React.FC<{ className: string }> | string | LucideIcon;
|
||||
children: React.ReactNode;
|
||||
// Emphasises the text in the block. For now only
|
||||
// applying in dark mode.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { CreditCard, Zap } from "lucide-react";
|
||||
|
||||
export function getPayIcon(currency: string): React.FC<{ className: string }> | string {
|
||||
import type { LucideIcon } from "@calcom/ui/components/icon";
|
||||
|
||||
export function getPayIcon(currency: string): React.FC<{ className: string }> | string | LucideIcon {
|
||||
return currency !== "BTC" ? CreditCard : Zap;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { CreditCard } from "lucide-react";
|
||||
|
||||
import type { LucideIcon } from "@calcom/ui/components/icon";
|
||||
import { SatSymbol } from "@calcom/ui/components/icon/SatSymbol";
|
||||
|
||||
export function getPriceIcon(currency: string): React.FC<{ className: string }> | string {
|
||||
export function getPriceIcon(currency: string): React.FC<{ className: string }> | string | LucideIcon {
|
||||
return currency !== "BTC" ? CreditCard : (SatSymbol as React.FC<{ className: string }>);
|
||||
}
|
||||
|
||||
@@ -6,9 +6,10 @@ import { trpc } from "@calcom/trpc/react";
|
||||
import type { SVGComponent } from "@calcom/types/SVGComponent";
|
||||
import { CreateButtonWithTeamsList, EmptyScreen as ClassicEmptyScreen, showToast } from "@calcom/ui";
|
||||
import { Mail, Smartphone, Zap } from "@calcom/ui/components/icon";
|
||||
import type { LucideIcon } from "@calcom/ui/components/icon";
|
||||
|
||||
type WorkflowExampleType = {
|
||||
Icon: SVGComponent;
|
||||
Icon: SVGComponent | LucideIcon;
|
||||
text: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ const publicEventSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||
eventName: true,
|
||||
slug: true,
|
||||
isInstantEvent: true,
|
||||
aiPhoneCallConfig: true,
|
||||
schedulingType: true,
|
||||
length: true,
|
||||
locations: true,
|
||||
@@ -302,6 +303,7 @@ export const getPublicEvent = async (
|
||||
},
|
||||
isDynamic: false,
|
||||
isInstantEvent: eventWithUserProfiles.isInstantEvent,
|
||||
aiPhoneCallConfig: eventWithUserProfiles.aiPhoneCallConfig,
|
||||
assignAllTeamMembers: event.assignAllTeamMembers,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -58,6 +58,14 @@ export type FormValues = {
|
||||
credentialId?: number;
|
||||
teamName?: string;
|
||||
}[];
|
||||
aiPhoneCallConfig: {
|
||||
generalPrompt: string;
|
||||
enabled: boolean;
|
||||
beginMessage: string;
|
||||
yourPhoneNumber: string;
|
||||
numberToCall: string;
|
||||
guestName: string;
|
||||
};
|
||||
customInputs: CustomInputParsed[];
|
||||
schedule: number | null;
|
||||
periodType: PeriodType;
|
||||
|
||||
@@ -8,11 +8,12 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
Tooltip,
|
||||
} from "@calcom/ui";
|
||||
import type { LucideIcon } from "@calcom/ui/components/icon";
|
||||
import { Plus, Link, User, Check } from "@calcom/ui/components/icon";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
|
||||
type Option = { value: "event-type" | "user"; label: string; StartIcon?: SVGComponent };
|
||||
type Option = { value: "event-type" | "user"; label: string; StartIcon?: SVGComponent | LucideIcon };
|
||||
|
||||
export const FilterType = () => {
|
||||
const { t } = useLocale();
|
||||
|
||||
@@ -79,6 +79,7 @@ import {
|
||||
Tooltip,
|
||||
useCalcomTheme,
|
||||
} from "@calcom/ui";
|
||||
import type { LucideIcon } from "@calcom/ui/components/icon";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
@@ -100,6 +101,7 @@ import {
|
||||
Settings,
|
||||
User as UserIcon,
|
||||
Users,
|
||||
Sparkles,
|
||||
Zap,
|
||||
Check,
|
||||
} from "@calcom/ui/components/icon";
|
||||
@@ -504,7 +506,7 @@ function UserDropdown({ small }: UserDropdownProps) {
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
StartIcon={(props) => (
|
||||
StartIcon={(props: { className?: string }) => (
|
||||
<UserIcon className={classNames("text-default", props.className)} aria-hidden="true" />
|
||||
)}
|
||||
href="/settings/my-account/profile">
|
||||
@@ -514,7 +516,7 @@ function UserDropdown({ small }: UserDropdownProps) {
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
StartIcon={(props) => (
|
||||
StartIcon={(props: { className?: string }) => (
|
||||
<Settings className={classNames("text-default", props.className)} aria-hidden="true" />
|
||||
)}
|
||||
href="/settings/my-account/general">
|
||||
@@ -524,7 +526,7 @@ function UserDropdown({ small }: UserDropdownProps) {
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
StartIcon={(props) => (
|
||||
StartIcon={(props: { className?: string }) => (
|
||||
<Moon className={classNames("text-default", props.className)} aria-hidden="true" />
|
||||
)}
|
||||
href="/settings/my-account/out-of-office">
|
||||
@@ -549,7 +551,9 @@ function UserDropdown({ small }: UserDropdownProps) {
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
StartIcon={(props) => <HelpCircle aria-hidden="true" {...props} />}
|
||||
StartIcon={(props: { className?: string }) => (
|
||||
<HelpCircle aria-hidden="true" {...props} />
|
||||
)}
|
||||
onClick={() => setHelpOpen(true)}>
|
||||
{t("help")}
|
||||
</DropdownItem>
|
||||
@@ -565,7 +569,7 @@ function UserDropdown({ small }: UserDropdownProps) {
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
StartIcon={(props) => <LogOut aria-hidden="true" {...props} />}
|
||||
StartIcon={(props: { className?: string }) => <LogOut aria-hidden="true" {...props} />}
|
||||
onClick={() => signOut({ callbackUrl: "/auth/logout" })}>
|
||||
{t("sign_out")}
|
||||
</DropdownItem>
|
||||
@@ -585,7 +589,7 @@ export type NavigationItemType = {
|
||||
onClick?: React.MouseEventHandler<HTMLAnchorElement | HTMLButtonElement>;
|
||||
target?: HTMLAnchorElement["target"];
|
||||
badge?: React.ReactNode;
|
||||
icon?: SVGComponent;
|
||||
icon?: SVGComponent | LucideIcon;
|
||||
child?: NavigationItemType[];
|
||||
pro?: true;
|
||||
onlyMobile?: boolean;
|
||||
@@ -670,6 +674,11 @@ const navigation: NavigationItemType[] = [
|
||||
icon: FileText,
|
||||
isCurrent: ({ pathname }) => pathname?.startsWith("/apps/routing-forms/") ?? false,
|
||||
},
|
||||
{
|
||||
name: "Cal.ai",
|
||||
href: "/ai",
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
name: "workflows",
|
||||
href: "/workflows",
|
||||
|
||||
@@ -84,6 +84,7 @@ export const getEventTypeById = async ({
|
||||
description: true,
|
||||
length: true,
|
||||
isInstantEvent: true,
|
||||
aiPhoneCallConfig: true,
|
||||
offsetStart: true,
|
||||
hidden: true,
|
||||
locations: true,
|
||||
|
||||
@@ -40,6 +40,7 @@ export const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||
slotInterval: true,
|
||||
successRedirectUrl: true,
|
||||
isInstantEvent: true,
|
||||
aiPhoneCallConfig: true,
|
||||
assignAllTeamMembers: true,
|
||||
recurringEvent: true,
|
||||
});
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
"@tanstack/react-query": "^5.17.15",
|
||||
"class-variance-authority": "^0.4.0",
|
||||
"clsx": "^2.0.0",
|
||||
"lucide-react": "^0.171.0",
|
||||
"lucide-react": "^0.363.0",
|
||||
"react-use": "^17.4.2",
|
||||
"tailwind-merge": "^1.13.2",
|
||||
"tailwindcss": "^3.3.3",
|
||||
|
||||
@@ -45,6 +45,15 @@ export type Event = {
|
||||
eventName: string;
|
||||
slug: string;
|
||||
isInstantEvent: boolean;
|
||||
aiPhoneCallConfig: {
|
||||
eventTypeId: number;
|
||||
enabled: boolean;
|
||||
generalPrompt: string;
|
||||
beginMessage: string | null;
|
||||
yourPhoneNumber: string;
|
||||
numberToCall: string;
|
||||
guestName: string;
|
||||
};
|
||||
schedulingType: string;
|
||||
length: number;
|
||||
locations: string[]; // Define more specifically if possible
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "AIPhoneCallConfiguration" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"eventTypeId" INTEGER NOT NULL,
|
||||
"generalPrompt" TEXT NOT NULL,
|
||||
"yourPhoneNumber" TEXT NOT NULL,
|
||||
"numberToCall" TEXT NOT NULL,
|
||||
"guestName" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"beginMessage" TEXT,
|
||||
"llmId" TEXT,
|
||||
|
||||
CONSTRAINT "AIPhoneCallConfiguration_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AIPhoneCallConfiguration_eventTypeId_idx" ON "AIPhoneCallConfiguration"("eventTypeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AIPhoneCallConfiguration_eventTypeId_key" ON "AIPhoneCallConfiguration"("eventTypeId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AIPhoneCallConfiguration" ADD CONSTRAINT "AIPhoneCallConfiguration_eventTypeId_fkey" FOREIGN KEY ("eventTypeId") REFERENCES "EventType"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -76,7 +76,7 @@ model EventType {
|
||||
profileId Int?
|
||||
profile Profile? @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
|
||||
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
teamId Int?
|
||||
hashedLink HashedLink?
|
||||
bookings Booking[]
|
||||
@@ -86,40 +86,40 @@ model EventType {
|
||||
eventName String?
|
||||
customInputs EventTypeCustomInput[]
|
||||
parentId Int?
|
||||
parent EventType? @relation("managed_eventtype", fields: [parentId], references: [id], onDelete: Cascade)
|
||||
children EventType[] @relation("managed_eventtype")
|
||||
parent EventType? @relation("managed_eventtype", fields: [parentId], references: [id], onDelete: Cascade)
|
||||
children EventType[] @relation("managed_eventtype")
|
||||
/// @zod.custom(imports.eventTypeBookingFields)
|
||||
bookingFields Json?
|
||||
timeZone String?
|
||||
periodType PeriodType @default(UNLIMITED)
|
||||
periodType PeriodType @default(UNLIMITED)
|
||||
/// @zod.custom(imports.coerceToDate)
|
||||
periodStartDate DateTime?
|
||||
/// @zod.custom(imports.coerceToDate)
|
||||
periodEndDate DateTime?
|
||||
periodDays Int?
|
||||
periodCountCalendarDays Boolean?
|
||||
lockTimeZoneToggleOnBookingPage Boolean @default(false)
|
||||
requiresConfirmation Boolean @default(false)
|
||||
requiresBookerEmailVerification Boolean @default(false)
|
||||
lockTimeZoneToggleOnBookingPage Boolean @default(false)
|
||||
requiresConfirmation Boolean @default(false)
|
||||
requiresBookerEmailVerification Boolean @default(false)
|
||||
/// @zod.custom(imports.recurringEventType)
|
||||
recurringEvent Json?
|
||||
disableGuests Boolean @default(false)
|
||||
hideCalendarNotes Boolean @default(false)
|
||||
disableGuests Boolean @default(false)
|
||||
hideCalendarNotes Boolean @default(false)
|
||||
/// @zod.min(0)
|
||||
minimumBookingNotice Int @default(120)
|
||||
beforeEventBuffer Int @default(0)
|
||||
afterEventBuffer Int @default(0)
|
||||
minimumBookingNotice Int @default(120)
|
||||
beforeEventBuffer Int @default(0)
|
||||
afterEventBuffer Int @default(0)
|
||||
seatsPerTimeSlot Int?
|
||||
onlyShowFirstAvailableSlot Boolean @default(false)
|
||||
seatsShowAttendees Boolean? @default(false)
|
||||
seatsShowAvailabilityCount Boolean? @default(true)
|
||||
onlyShowFirstAvailableSlot Boolean @default(false)
|
||||
seatsShowAttendees Boolean? @default(false)
|
||||
seatsShowAvailabilityCount Boolean? @default(true)
|
||||
schedulingType SchedulingType?
|
||||
schedule Schedule? @relation(fields: [scheduleId], references: [id])
|
||||
schedule Schedule? @relation(fields: [scheduleId], references: [id])
|
||||
scheduleId Int?
|
||||
// price is deprecated. It has now moved to metadata.apps.stripe.price. Plan to drop this column.
|
||||
price Int @default(0)
|
||||
price Int @default(0)
|
||||
// currency is deprecated. It has now moved to metadata.apps.stripe.currency. Plan to drop this column.
|
||||
currency String @default("usd")
|
||||
currency String @default("usd")
|
||||
slotInterval Int?
|
||||
/// @zod.custom(imports.EventTypeMetaDataSchema)
|
||||
metadata Json?
|
||||
@@ -130,9 +130,10 @@ model EventType {
|
||||
bookingLimits Json?
|
||||
/// @zod.custom(imports.intervalLimitsType)
|
||||
durationLimits Json?
|
||||
isInstantEvent Boolean @default(false)
|
||||
assignAllTeamMembers Boolean @default(false)
|
||||
useEventTypeDestinationCalendarEmail Boolean @default(false)
|
||||
isInstantEvent Boolean @default(false)
|
||||
assignAllTeamMembers Boolean @default(false)
|
||||
useEventTypeDestinationCalendarEmail Boolean @default(false)
|
||||
aiPhoneCallConfig AIPhoneCallConfiguration?
|
||||
|
||||
secondaryEmailId Int?
|
||||
secondaryEmail SecondaryEmail? @relation(fields: [secondaryEmailId], references: [id], onDelete: Cascade)
|
||||
@@ -906,6 +907,22 @@ model Workflow {
|
||||
@@index([teamId])
|
||||
}
|
||||
|
||||
model AIPhoneCallConfiguration {
|
||||
id Int @id @default(autoincrement())
|
||||
eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
|
||||
eventTypeId Int
|
||||
generalPrompt String
|
||||
yourPhoneNumber String
|
||||
numberToCall String
|
||||
guestName String
|
||||
enabled Boolean @default(false)
|
||||
beginMessage String?
|
||||
llmId String?
|
||||
|
||||
@@unique([eventTypeId])
|
||||
@@index([eventTypeId])
|
||||
}
|
||||
|
||||
model WorkflowsOnEventTypes {
|
||||
id Int @id @default(autoincrement())
|
||||
workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 {
|
||||
@@ -591,6 +592,7 @@ export const allManagedEventTypeProps: { [k in keyof Omit<Prisma.EventTypeSelect
|
||||
title: true,
|
||||
description: true,
|
||||
isInstantEvent: true,
|
||||
aiPhoneCallConfig: true,
|
||||
currency: true,
|
||||
periodDays: true,
|
||||
position: true,
|
||||
@@ -688,3 +690,19 @@ 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",
|
||||
}),
|
||||
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",
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -8,6 +8,17 @@ export const EventTypeUpdateInput = _EventTypeModel
|
||||
/** Optional fields */
|
||||
.extend({
|
||||
isInstantEvent: z.boolean().optional(),
|
||||
aiPhoneCallConfig: z
|
||||
.object({
|
||||
generalPrompt: z.string(),
|
||||
enabled: z.boolean(),
|
||||
beginMessage: z.string().nullable(),
|
||||
yourPhoneNumber: z.string().default(""),
|
||||
numberToCall: z.string().default(""),
|
||||
guestName: z.string().default(""),
|
||||
})
|
||||
.optional(),
|
||||
calAiPhoneScript: z.string().optional(),
|
||||
customInputs: z.array(customInputSchema).optional(),
|
||||
destinationCalendar: _DestinationCalendarModel
|
||||
.pick({
|
||||
|
||||
@@ -49,12 +49,21 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
bookingFields,
|
||||
offsetStart,
|
||||
secondaryEmailId,
|
||||
aiPhoneCallConfig,
|
||||
...rest
|
||||
} = input;
|
||||
|
||||
const eventType = await ctx.prisma.eventType.findUniqueOrThrow({
|
||||
where: { id },
|
||||
select: {
|
||||
aiPhoneCallConfig: {
|
||||
select: {
|
||||
generalPrompt: true,
|
||||
beginMessage: true,
|
||||
enabled: true,
|
||||
llmId: true,
|
||||
},
|
||||
},
|
||||
children: {
|
||||
select: {
|
||||
userId: true,
|
||||
@@ -345,6 +354,27 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (aiPhoneCallConfig) {
|
||||
if (aiPhoneCallConfig.enabled) {
|
||||
await ctx.prisma.aIPhoneCallConfiguration.upsert({
|
||||
where: {
|
||||
eventTypeId: id,
|
||||
},
|
||||
update: aiPhoneCallConfig,
|
||||
create: {
|
||||
...aiPhoneCallConfig,
|
||||
eventTypeId: id,
|
||||
},
|
||||
});
|
||||
} else if (!aiPhoneCallConfig.enabled && eventType.aiPhoneCallConfig) {
|
||||
await ctx.prisma.aIPhoneCallConfiguration.delete({
|
||||
where: {
|
||||
eventTypeId: id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updatedEventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||
slug: true,
|
||||
schedulingType: true,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ZVerifyCodeInputSchema } from "@calcom/prisma/zod-utils";
|
||||
import { AIPhoneSettingSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import authedProcedure, {
|
||||
authedAdminProcedure,
|
||||
@@ -141,4 +142,11 @@ export const viewerOrganizationsRouter = router({
|
||||
const handler = await importHandler(namespaced("adminDelete"), () => import("./adminDelete.handler"));
|
||||
return handler(opts);
|
||||
}),
|
||||
createPhoneCall: authedProcedure.input(AIPhoneSettingSchema).mutation(async (opts) => {
|
||||
const handler = await importHandler(
|
||||
namespaced("createPhoneCall"),
|
||||
() => import("./createPhoneCall.handler")
|
||||
);
|
||||
return handler(opts);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { handleErrorsJson } from "@calcom/lib/errors";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import type { AIPhoneSettingSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
type CreatePhoneCallProps = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
prisma: PrismaClient;
|
||||
};
|
||||
input: z.infer<typeof AIPhoneSettingSchema>;
|
||||
};
|
||||
|
||||
const createRetellLLMSchema = z
|
||||
.object({
|
||||
llm_id: z.string(),
|
||||
llm_websocket_url: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const getRetellLLMSchema = z
|
||||
.object({
|
||||
general_prompt: z.string(),
|
||||
begin_message: z.string().nullable(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const createPhoneSchema = z
|
||||
.object({
|
||||
call_id: z.string(),
|
||||
agent_id: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const getPhoneNumberSchema = z
|
||||
.object({
|
||||
agent_id: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const fetcher = async (endpoint: string, init?: RequestInit | undefined) => {
|
||||
return fetch(`https://api.retellai.com${endpoint}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.RETELL_AI_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
...init?.headers,
|
||||
},
|
||||
...init,
|
||||
}).then(handleErrorsJson);
|
||||
};
|
||||
|
||||
const createPhoneCallHandler = async ({ input, ctx }: CreatePhoneCallProps) => {
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: `createPhoneCall:${ctx.user.id}`,
|
||||
});
|
||||
|
||||
const { yourPhoneNumber, numberToCall, guestName, eventTypeId, beginMessage, generalPrompt, calApiKey } =
|
||||
input;
|
||||
|
||||
const aiPhoneCallConfig = await ctx.prisma.aIPhoneCallConfiguration.upsert({
|
||||
where: {
|
||||
eventTypeId,
|
||||
},
|
||||
update: {
|
||||
beginMessage,
|
||||
generalPrompt,
|
||||
enabled: true,
|
||||
guestName,
|
||||
numberToCall,
|
||||
yourPhoneNumber,
|
||||
},
|
||||
create: {
|
||||
eventTypeId,
|
||||
beginMessage,
|
||||
generalPrompt,
|
||||
enabled: true,
|
||||
guestName,
|
||||
numberToCall,
|
||||
yourPhoneNumber,
|
||||
},
|
||||
});
|
||||
|
||||
let llmWebSocketUrlToBeUpdated = null;
|
||||
|
||||
if (!aiPhoneCallConfig.llmId) {
|
||||
const createdRetellLLM = await fetcher("/create-retell-llm", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
general_prompt: generalPrompt,
|
||||
begin_message: beginMessage,
|
||||
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);
|
||||
|
||||
await ctx.prisma.aIPhoneCallConfiguration.update({
|
||||
where: {
|
||||
eventTypeId,
|
||||
},
|
||||
data: {
|
||||
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,
|
||||
}),
|
||||
}).then(getRetellLLMSchema.parse);
|
||||
|
||||
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 },
|
||||
}),
|
||||
}).then(createPhoneSchema.parse);
|
||||
|
||||
logger.debug("Create Call Response", createPhoneCallRes);
|
||||
|
||||
return createPhoneCallRes;
|
||||
};
|
||||
|
||||
export default createPhoneCallHandler;
|
||||
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
|
||||
import { forwardRef } from "react";
|
||||
import type { IconType } from "react-icons";
|
||||
|
||||
import type { LucideIcon } from "@calcom/ui/components/icon";
|
||||
import { CheckCircle2, Info, XCircle, AlertTriangle } from "@calcom/ui/components/icon";
|
||||
|
||||
export interface AlertProps {
|
||||
@@ -15,7 +16,7 @@ export interface AlertProps {
|
||||
iconClassName?: string;
|
||||
// @TODO: Success and info shouldn't exist as per design?
|
||||
severity: "success" | "warning" | "error" | "info" | "neutral" | "green";
|
||||
CustomIcon?: IconType;
|
||||
CustomIcon?: IconType | LucideIcon;
|
||||
customIconColor?: string;
|
||||
}
|
||||
export const Alert = forwardRef<HTMLDivElement, AlertProps>((props, ref) => {
|
||||
|
||||
@@ -273,7 +273,7 @@ const InstallAppButtonChild = ({
|
||||
type="button"
|
||||
disabled={isInstalledTeamOrUser}
|
||||
key={team.id}
|
||||
StartIcon={(props) => (
|
||||
StartIcon={(props: { className?: string }) => (
|
||||
<Avatar
|
||||
alt={team.logo || ""}
|
||||
imageSrc={team.logo || `${WEBAPP_URL}/${team.logo}/avatar.png`} // if no image, use default avatar
|
||||
|
||||
@@ -5,6 +5,7 @@ import { GoPrimitiveDot } from "react-icons/go";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import type { SVGComponent } from "@calcom/types/SVGComponent";
|
||||
import type { LucideIcon } from "@calcom/ui/components/icon";
|
||||
|
||||
export const badgeStyles = cva("font-medium inline-flex items-center justify-center rounded gap-x-1", {
|
||||
variants: {
|
||||
@@ -36,7 +37,7 @@ type InferredBadgeStyles = VariantProps<typeof badgeStyles>;
|
||||
|
||||
type IconOrDot =
|
||||
| {
|
||||
startIcon?: SVGComponent;
|
||||
startIcon?: SVGComponent | LucideIcon;
|
||||
withDot?: unknown;
|
||||
}
|
||||
| { startIcon?: unknown; withDot?: boolean };
|
||||
|
||||
@@ -6,6 +6,7 @@ import React, { forwardRef } from "react";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import type { SVGComponent } from "@calcom/types/SVGComponent";
|
||||
import type { LucideIcon } from "@calcom/ui/components/icon";
|
||||
|
||||
import { Plus } from "../icon";
|
||||
import { Tooltip } from "../tooltip";
|
||||
@@ -17,9 +18,9 @@ export type ButtonBaseProps = {
|
||||
/** Action that happens when the button is clicked */
|
||||
onClick?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
||||
/**Left aligned icon*/
|
||||
StartIcon?: SVGComponent | React.ElementType;
|
||||
StartIcon?: SVGComponent | React.ElementType | LucideIcon;
|
||||
/**Right aligned icon */
|
||||
EndIcon?: SVGComponent;
|
||||
EndIcon?: SVGComponent | LucideIcon;
|
||||
shallow?: boolean;
|
||||
/**Tool tip used when icon size is set to small */
|
||||
tooltip?: string;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from "react";
|
||||
|
||||
import type { SVGComponent } from "@calcom/types/SVGComponent";
|
||||
import type { LucideIcon } from "@calcom/ui/components/icon";
|
||||
|
||||
interface LinkIconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
Icon: SVGComponent;
|
||||
Icon: SVGComponent | LucideIcon;
|
||||
}
|
||||
|
||||
export default function LinkIconButton(props: LinkIconButtonProps) {
|
||||
|
||||
@@ -113,7 +113,7 @@ export function CreateButton(props: CreateBtnProps) {
|
||||
<DropdownItem
|
||||
type="button"
|
||||
data-testid={`option${option.teamId ? "-team" : ""}-${idx}`}
|
||||
StartIcon={(props) => (
|
||||
StartIcon={(props: { className?: string }) => (
|
||||
<Avatar alt={option.label || ""} imageSrc={option.image} size="sm" {...props} />
|
||||
)}
|
||||
onClick={() =>
|
||||
|
||||
@@ -9,6 +9,7 @@ import classNames from "@calcom/lib/classNames";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { SVGComponent } from "@calcom/types/SVGComponent";
|
||||
import type { LucideIcon } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { ButtonProps } from "../../components/button";
|
||||
import { Button } from "../../components/button";
|
||||
@@ -85,7 +86,7 @@ type DialogContentProps = React.ComponentProps<(typeof DialogPrimitive)["Content
|
||||
description?: string | JSX.Element | null;
|
||||
closeText?: string;
|
||||
actionDisabled?: boolean;
|
||||
Icon?: SVGComponent;
|
||||
Icon?: SVGComponent | LucideIcon;
|
||||
enableOverflow?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { forwardRef } from "react";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import type { SVGComponent } from "@calcom/types/SVGComponent";
|
||||
import { CheckCircle } from "@calcom/ui/components/icon";
|
||||
import type { LucideIcon } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { ButtonColor } from "../../button/Button";
|
||||
|
||||
@@ -105,8 +106,8 @@ DropdownMenuRadioItem.displayName = "DropdownMenuRadioItem";
|
||||
type DropdownItemProps = {
|
||||
children: React.ReactNode;
|
||||
color?: ButtonColor;
|
||||
StartIcon?: SVGComponent;
|
||||
EndIcon?: SVGComponent;
|
||||
StartIcon?: SVGComponent | LucideIcon;
|
||||
EndIcon?: SVGComponent | LucideIcon;
|
||||
href?: string;
|
||||
disabled?: boolean;
|
||||
childrenClassName?: string;
|
||||
|
||||
@@ -4,6 +4,7 @@ import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useUrlMatchesCurrentUrl } from "@calcom/lib/hooks/useUrlMatchesCurrentUrl";
|
||||
import type { SVGComponent } from "@calcom/types/SVGComponent";
|
||||
import type { LucideIcon } from "@calcom/ui/components/icon";
|
||||
|
||||
import { Avatar } from "../../avatar";
|
||||
import { SkeletonText } from "../../skeleton";
|
||||
@@ -16,7 +17,7 @@ export type HorizontalTabItemProps = {
|
||||
href: string;
|
||||
linkShallow?: boolean;
|
||||
linkScroll?: boolean;
|
||||
icon?: SVGComponent;
|
||||
icon?: SVGComponent | LucideIcon;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useUrlMatchesCurrentUrl } from "@calcom/lib/hooks/useUrlMatchesCurrentUrl";
|
||||
import type { SVGComponent } from "@calcom/types/SVGComponent";
|
||||
import type { LucideIcon } from "@calcom/ui/components/icon";
|
||||
|
||||
import { ChevronRight, ExternalLink } from "../../icon";
|
||||
import { Skeleton } from "../../skeleton";
|
||||
@@ -12,7 +13,7 @@ import { Skeleton } from "../../skeleton";
|
||||
export type VerticalTabItemProps = {
|
||||
name: string;
|
||||
info?: string;
|
||||
icon?: SVGComponent;
|
||||
icon?: SVGComponent | LucideIcon;
|
||||
disabled?: boolean;
|
||||
children?: VerticalTabItemProps[];
|
||||
textClassNames?: string;
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"cmdk": "^0.2.0",
|
||||
"cmk": "^0.1.1",
|
||||
"downshift": "^6.1.9",
|
||||
"lucide-react": "^0.171.0",
|
||||
"lucide-react": "^0.363.0",
|
||||
"next": "^13.5.4",
|
||||
"next-seo": "^6.0.0",
|
||||
"react": "^18.2.0",
|
||||
|
||||
@@ -340,6 +340,7 @@
|
||||
"RAILWAY_STATIC_URL",
|
||||
"RENDER_EXTERNAL_URL",
|
||||
"RESERVED_SUBDOMAINS",
|
||||
"RETELL_AI_KEY",
|
||||
"SALESFORCE_CONSUMER_KEY",
|
||||
"SALESFORCE_CONSUMER_SECRET",
|
||||
"SAML_ADMINS",
|
||||
|
||||
@@ -4038,7 +4038,7 @@ __metadata:
|
||||
"@vitejs/plugin-react": ^2.2.0
|
||||
class-variance-authority: ^0.4.0
|
||||
clsx: ^2.0.0
|
||||
lucide-react: ^0.171.0
|
||||
lucide-react: ^0.363.0
|
||||
react-use: ^17.4.2
|
||||
rollup-plugin-node-builtins: ^2.1.2
|
||||
tailwind-merge: ^1.13.2
|
||||
@@ -5168,7 +5168,7 @@ __metadata:
|
||||
cmdk: ^0.2.0
|
||||
cmk: ^0.1.1
|
||||
downshift: ^6.1.9
|
||||
lucide-react: ^0.171.0
|
||||
lucide-react: ^0.363.0
|
||||
next: ^13.5.4
|
||||
next-seo: ^6.0.0
|
||||
react: ^18.2.0
|
||||
@@ -5446,7 +5446,7 @@ __metadata:
|
||||
i18n-unused: ^0.13.0
|
||||
iframe-resizer-react: ^1.1.0
|
||||
keen-slider: ^6.8.0
|
||||
lucide-react: ^0.171.0
|
||||
lucide-react: ^0.363.0
|
||||
micro: ^10.0.1
|
||||
next: ^14.1
|
||||
next-auth: ^4.22.1
|
||||
@@ -20073,7 +20073,7 @@ __metadata:
|
||||
jest-diff: ^29.5.0
|
||||
jsdom: ^22.0.0
|
||||
lint-staged: ^12.5.0
|
||||
lucide-react: ^0.171.0
|
||||
lucide-react: ^0.363.0
|
||||
mailhog: ^4.16.0
|
||||
next-router-mock: ^0.9.12
|
||||
node-ical: ^0.16.1
|
||||
@@ -32846,12 +32846,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lucide-react@npm:^0.171.0":
|
||||
version: 0.171.0
|
||||
resolution: "lucide-react@npm:0.171.0"
|
||||
"lucide-react@npm:^0.363.0":
|
||||
version: 0.363.0
|
||||
resolution: "lucide-react@npm:0.363.0"
|
||||
peerDependencies:
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0
|
||||
checksum: 768ffe368c52a518ee339203d86ff4479989ab4d79c0716f721900c4bb7392ef6ff7a14807f6a685abd74d27f4c1778170bff77a0ab4c3e06c17944b557d8300
|
||||
checksum: abe8fad469a2f14181eca6f3682403c5d53a4a354f333b0f7d3b575471d451c6f3fd36f59d96745a5a823e4cc55eeb2dcda70774a6fe438834ea3fb6fa5b75af
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user