Skip to content

Commit aa48e72

Browse files
feat: inbound calls in cal ai (calcom#23890)
* feat: lang support * fix: type errors * feat: select voice agent * refactor: address feedback * refactor: address feedback * refactor: missing import * fix: types * feat: add inbound calls * chore: formatting * chore * feat: finish inbound call * chore: formatting * fix: update bug * fix: types * refactor: Agent Configuration Sheet (calcom#23930) * refactor: agent configuration sheet * chore: use default phone numbre * refactor: improvements * refactor: improvements * fix: types * fix: feedback * chore: * fix: feedback * fix: prompt * fix: review * fix: review * refactor: class * refactor: class * refactor: rename * Update apps/web/public/static/locales/en/common.json * Update apps/web/public/static/locales/en/common.json * chore: update set value * fix: remove index * fix: type error * fix: update tetss * fix: use logger * refactor: don't use static * fix: type * fix: schema * refactor: --------- Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
1 parent bec49ef commit aa48e72

74 files changed

Lines changed: 2981 additions & 1681 deletions

File tree

Some content is hidden

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

apps/web/app/api/webhooks/retell-ai/__tests__/route.test.ts

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ import type { NextRequest } from "next/server";
22
import { Retell } from "retell-sdk";
33
import { describe, it, expect, vi, beforeEach } from "vitest";
44

5-
import { PrismaAgentRepository } from "@calcom/lib/server/repository/PrismaAgentRepository";
6-
import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository";
75
import type { CalAiPhoneNumber, User, Team, Agent } from "@calcom/prisma/client";
86

97
import { POST } from "../route";
@@ -81,16 +79,19 @@ vi.mock("@calcom/features/ee/billing/credit-service", () => ({
8179
})),
8280
}));
8381

82+
const mockFindByPhoneNumber = vi.fn();
83+
const mockFindByProviderAgentId = vi.fn();
84+
8485
vi.mock("@calcom/lib/server/repository/PrismaPhoneNumberRepository", () => ({
85-
PrismaPhoneNumberRepository: {
86-
findByPhoneNumber: vi.fn(),
87-
},
86+
PrismaPhoneNumberRepository: vi.fn().mockImplementation(() => ({
87+
findByPhoneNumber: mockFindByPhoneNumber,
88+
})),
8889
}));
8990

9091
vi.mock("@calcom/lib/server/repository/PrismaAgentRepository", () => ({
91-
PrismaAgentRepository: {
92-
findByProviderAgentId: vi.fn(),
93-
},
92+
PrismaAgentRepository: vi.fn().mockImplementation(() => ({
93+
findByProviderAgentId: mockFindByProviderAgentId,
94+
})),
9495
}));
9596

9697
vi.mock("next/server", () => ({
@@ -196,7 +197,7 @@ describe("Retell AI Webhook Handler", () => {
196197
team: null,
197198
};
198199

199-
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
200+
mockFindByPhoneNumber.mockResolvedValue(mockPhoneNumber);
200201

201202
mockHasAvailableCredits.mockResolvedValue(true);
202203
mockChargeCredits.mockResolvedValue(undefined);
@@ -255,7 +256,7 @@ describe("Retell AI Webhook Handler", () => {
255256
user: null,
256257
};
257258

258-
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockTeamPhoneNumber);
259+
mockFindByPhoneNumber.mockResolvedValue(mockTeamPhoneNumber);
259260

260261
mockHasAvailableCredits.mockResolvedValue(true);
261262
mockChargeCredits.mockResolvedValue(undefined);
@@ -314,12 +315,12 @@ describe("Retell AI Webhook Handler", () => {
314315
const response = await callPOST(request);
315316

316317
expect(response.status).toBe(200);
317-
expect(PrismaPhoneNumberRepository.findByPhoneNumber).not.toHaveBeenCalled();
318+
expect(mockFindByPhoneNumber).not.toHaveBeenCalled();
318319
});
319320

320321
it("should handle phone number not found", async () => {
321322
vi.mocked(Retell.verify).mockReturnValue(true);
322-
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(null);
323+
mockFindByPhoneNumber.mockResolvedValue(null);
323324

324325
const body: RetellWebhookBody = {
325326
event: "call_analyzed",
@@ -365,7 +366,7 @@ describe("Retell AI Webhook Handler", () => {
365366
team: null,
366367
};
367368

368-
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
369+
mockFindByPhoneNumber.mockResolvedValue(mockPhoneNumber);
369370

370371
mockHasAvailableCredits.mockResolvedValue(false);
371372

@@ -440,7 +441,7 @@ describe("Retell AI Webhook Handler", () => {
440441
team: null,
441442
};
442443

443-
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
444+
mockFindByPhoneNumber.mockResolvedValue(mockPhoneNumber);
444445
mockHasAvailableCredits.mockResolvedValue(true);
445446
mockChargeCredits.mockResolvedValue(undefined);
446447

@@ -494,7 +495,7 @@ describe("Retell AI Webhook Handler", () => {
494495
user: { id: 42, email: "u@example.com", name: "U" },
495496
team: null,
496497
};
497-
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
498+
mockFindByPhoneNumber.mockResolvedValue(mockPhoneNumber);
498499
mockHasAvailableCredits.mockResolvedValue(true);
499500
mockChargeCredits.mockResolvedValue(undefined);
500501

@@ -544,7 +545,7 @@ describe("Retell AI Webhook Handler", () => {
544545
user: { id: 1, email: "test@example.com", name: "Test User" },
545546
team: null,
546547
};
547-
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
548+
mockFindByPhoneNumber.mockResolvedValue(mockPhoneNumber);
548549
mockChargeCredits.mockResolvedValue({ userId: 1 });
549550

550551
const body: RetellWebhookBody = {
@@ -596,7 +597,7 @@ describe("Retell AI Webhook Handler", () => {
596597
user: { id: 1, email: "test@example.com", name: "Test User" },
597598
team: null,
598599
};
599-
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
600+
mockFindByPhoneNumber.mockResolvedValue(mockPhoneNumber);
600601

601602
const body: RetellWebhookBody = {
602603
event: "call_analyzed",
@@ -661,7 +662,7 @@ describe("Retell AI Webhook Handler", () => {
661662
user: { id: 1, email: "test@example.com", name: "Test User" },
662663
team: null,
663664
};
664-
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
665+
mockFindByPhoneNumber.mockResolvedValue(mockPhoneNumber);
665666

666667
// Mock chargeCredits to throw an error
667668
mockChargeCredits.mockRejectedValue(new Error("Credit service error"));
@@ -695,7 +696,7 @@ describe("Retell AI Webhook Handler", () => {
695696
describe("Web Call Tests", () => {
696697
const mockAgent: Pick<
697698
Agent,
698-
"id" | "name" | "providerAgentId" | "enabled" | "userId" | "teamId" | "createdAt" | "updatedAt"
699+
"id" | "name" | "providerAgentId" | "enabled" | "userId" | "teamId" | "createdAt" | "updatedAt" | "inboundEventTypeId"
699700
> = {
700701
id: "agent-123",
701702
name: "Test Agent",
@@ -705,6 +706,7 @@ describe("Retell AI Webhook Handler", () => {
705706
teamId: null,
706707
createdAt: new Date(),
707708
updatedAt: new Date(),
709+
inboundEventTypeId: null,
708710
};
709711

710712
beforeEach(() => {
@@ -713,7 +715,7 @@ describe("Retell AI Webhook Handler", () => {
713715
});
714716

715717
it("should process web call with valid agent and charge credits", async () => {
716-
vi.mocked(PrismaAgentRepository.findByProviderAgentId).mockResolvedValue(mockAgent);
718+
mockFindByProviderAgentId.mockResolvedValue(mockAgent);
717719
mockChargeCredits.mockResolvedValue(undefined);
718720

719721
const body: RetellWebhookBody = {
@@ -739,7 +741,7 @@ describe("Retell AI Webhook Handler", () => {
739741
const data = await response.json();
740742
expect(data.success).toBe(true);
741743

742-
expect(PrismaAgentRepository.findByProviderAgentId).toHaveBeenCalledWith({
744+
expect(mockFindByProviderAgentId).toHaveBeenCalledWith({
743745
providerAgentId: "agent_5e3e0d29d692172c2c24d8f9a7",
744746
});
745747

@@ -755,8 +757,8 @@ describe("Retell AI Webhook Handler", () => {
755757
});
756758

757759
it("should handle web call with team agent", async () => {
758-
const teamAgent = { ...mockAgent, userId: 2, teamId: 10 };
759-
vi.mocked(PrismaAgentRepository.findByProviderAgentId).mockResolvedValue(teamAgent);
760+
const teamAgent = { ...mockAgent, userId: 2, teamId: 10, inboundEventTypeId: null };
761+
mockFindByProviderAgentId.mockResolvedValue(teamAgent);
760762
mockChargeCredits.mockResolvedValue(undefined);
761763

762764
const body: RetellWebhookBody = {
@@ -789,7 +791,7 @@ describe("Retell AI Webhook Handler", () => {
789791
});
790792

791793
it("should handle web call without from_number", async () => {
792-
vi.mocked(PrismaAgentRepository.findByProviderAgentId).mockResolvedValue(mockAgent);
794+
mockFindByProviderAgentId.mockResolvedValue(mockAgent);
793795
mockChargeCredits.mockResolvedValue(undefined);
794796

795797
const body: RetellWebhookBody = {
@@ -810,8 +812,8 @@ describe("Retell AI Webhook Handler", () => {
810812
const response = await callPOST(request);
811813

812814
expect(response.status).toBe(200);
813-
expect(PrismaAgentRepository.findByProviderAgentId).toHaveBeenCalled();
814-
expect(PrismaPhoneNumberRepository.findByPhoneNumber).not.toHaveBeenCalled();
815+
expect(mockFindByProviderAgentId).toHaveBeenCalled();
816+
expect(mockFindByPhoneNumber).not.toHaveBeenCalled();
815817
expect(mockChargeCredits).toHaveBeenCalled();
816818
});
817819

@@ -833,12 +835,12 @@ describe("Retell AI Webhook Handler", () => {
833835
const response = await callPOST(request);
834836

835837
expect(response.status).toBe(200);
836-
expect(PrismaAgentRepository.findByProviderAgentId).not.toHaveBeenCalled();
838+
expect(mockFindByProviderAgentId).not.toHaveBeenCalled();
837839
expect(mockChargeCredits).not.toHaveBeenCalled();
838840
});
839841

840842
it("should handle web call with agent not found", async () => {
841-
vi.mocked(PrismaAgentRepository.findByProviderAgentId).mockResolvedValue(null);
843+
mockFindByProviderAgentId.mockResolvedValue(null);
842844

843845
const body: RetellWebhookBody = {
844846
event: "call_analyzed",
@@ -860,7 +862,7 @@ describe("Retell AI Webhook Handler", () => {
860862
const response = await callPOST(request);
861863

862864
expect(response.status).toBe(200);
863-
expect(PrismaAgentRepository.findByProviderAgentId).toHaveBeenCalledWith({
865+
expect(mockFindByProviderAgentId).toHaveBeenCalledWith({
864866
providerAgentId: "non-existent-agent",
865867
});
866868
expect(mockChargeCredits).not.toHaveBeenCalled();

apps/web/app/api/webhooks/retell-ai/route.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import logger from "@calcom/lib/logger";
99
import { safeStringify } from "@calcom/lib/safeStringify";
1010
import { PrismaAgentRepository } from "@calcom/lib/server/repository/PrismaAgentRepository";
1111
import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository";
12+
import prisma from "@calcom/prisma";
1213
import { CreditUsageType } from "@calcom/prisma/enums";
1314

1415
const log = logger.getSubLogger({ prefix: ["retell-ai-webhook"] });
@@ -133,7 +134,7 @@ async function handleCallAnalyzed(callData: any) {
133134
);
134135
return {
135136
success: true,
136-
message: `Invalid or missing call_cost.total_duration_seconds for call ${call_id}`
137+
message: `Invalid or missing call_cost.total_duration_seconds for call ${call_id}`,
137138
};
138139
}
139140

@@ -146,29 +147,30 @@ async function handleCallAnalyzed(callData: any) {
146147
log.error(`Web call ${call_id} missing agent_id, cannot charge credits`);
147148
return {
148149
success: false,
149-
message: `Web call ${call_id} missing agent_id, cannot charge credits`
150+
message: `Web call ${call_id} missing agent_id, cannot charge credits`,
150151
};
151152
}
152153

153-
const agent = await PrismaAgentRepository.findByProviderAgentId({
154+
const agentRepo = new PrismaAgentRepository(prisma);
155+
const agent = await agentRepo.findByProviderAgentId({
154156
providerAgentId: agent_id,
155157
});
156158

157159
if (!agent) {
158160
log.error(`No agent found for providerAgentId ${agent_id}, call ${call_id}`);
159161
return {
160162
success: false,
161-
message: `No agent found for providerAgentId ${agent_id}, call ${call_id}`
163+
message: `No agent found for providerAgentId ${agent_id}, call ${call_id}`,
162164
};
163165
}
164166

165-
166167
userId = agent.userId ?? undefined;
167168
teamId = agent.teamId ?? undefined;
168169

169170
log.info(`Processing web call ${call_id} for agent ${agent_id}, user ${userId}, team ${teamId}`);
170171
} else {
171-
const phoneNumber = await PrismaPhoneNumberRepository.findByPhoneNumber({
172+
const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
173+
const phoneNumber = await phoneNumberRepo.findByPhoneNumber({
172174
phoneNumber: from_number,
173175
});
174176

apps/web/public/icons/sprite.svg

Lines changed: 5 additions & 0 deletions
Loading

apps/web/public/static/locales/en/common.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,8 @@
310310
"register_now": "Register now",
311311
"register": "Register",
312312
"page_doesnt_exist": "This page does not exist.",
313+
"no_active_phone_number_available": "No active phone number available",
314+
"workflow_step_not_configured": "Workflow step not configured",
313315
"check_spelling_mistakes_or_go_back": "Check for spelling mistakes or go back to the previous page.",
314316
"404_page_not_found": "404: This page could not be found.",
315317
"booker_event_not_found": "We could not find the event you are trying to book.",
@@ -837,6 +839,7 @@
837839
"please_enter_phone_number": "Please enter a phone number",
838840
"agent_updated_successfully": "Agent updated successfully",
839841
"agent_created_successfully": "Agent created successfully",
842+
"agent_event_type_updated_successfully": "Event type added successfully",
840843
"phone_number_unsubscribed_successfully": "Phone number unsubscribed successfully",
841844
"general_prompt_description": "This prompt defines the agent's role and primary objectives",
842845
"prompt": "Prompt",
@@ -3756,5 +3759,19 @@
37563759
"voice_id": "Voice ID",
37573760
"use_voice": "Use Voice",
37583761
"current_voice": "Current Voice",
3762+
"setup_incoming_agent": "Set up incoming agent",
3763+
"setup_incoming_agent_description": "Configure an AI agent to handle incoming calls on your phone number",
3764+
"connect_a_phone_number_first": "Connect a Phone Number first",
3765+
"setup_agent_for_incoming_calls": "Set up Agent for incoming calls",
3766+
"configure_agent_to_handle_incoming_calls": "Configure agent to handle incoming calls",
3767+
"incoming_calls": "Incoming Calls",
3768+
"outgoing_calls": "Outgoing Calls",
3769+
"inbound_agent_setup_success": "Inbound agent setup successful",
3770+
"inbound_agent_configured": "Inbound agent configured",
3771+
"setup_inbound_agent": "Set up Inbound Agent",
3772+
"please_select_event_type_first": "Please select an event type first",
3773+
"edit_configuration": "Edit Configuration",
3774+
"select_event_type_for_inbound_calls": "Inbound calls can book only one event type. Select the event type where meetings will be scheduled when callers reach your agent.",
3775+
"setup_inbound_agent_for_incoming_calls": "Set up inbound agent for incoming calls",
37593776
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
37603777
}

packages/features/calAIPhone/AIPhoneServiceRegistry.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ describe("AIPhoneServiceRegistry", () => {
5656
updatePhoneNumberWithAgents: vi.fn().mockResolvedValue({ message: "test-message" }),
5757
listAgents: vi.fn().mockResolvedValue({ totalCount: 1, filtered: [] }),
5858
getAgentWithDetails: vi.fn().mockResolvedValue({ agent_id: "test-agent" }),
59-
createAgent: vi
59+
createOutboundAgent: vi
6060
.fn()
6161
.mockResolvedValue({ id: "test-id", providerAgentId: "test-provider-id", message: "test-message" }),
6262
updateAgentConfiguration: vi.fn().mockResolvedValue({ message: "test-message" }),
@@ -285,7 +285,7 @@ describe("createAIPhoneServiceProvider", () => {
285285
updatePhoneNumberWithAgents: vi.fn(),
286286
listAgents: vi.fn(),
287287
getAgentWithDetails: vi.fn(),
288-
createAgent: vi.fn(),
288+
createOutboundAgent: vi.fn(),
289289
updateAgentConfiguration: vi.fn(),
290290
deleteAgent: vi.fn(),
291291
createTestCall: vi.fn(),
@@ -410,7 +410,7 @@ describe("createDefaultAIPhoneServiceProvider", () => {
410410
updatePhoneNumberWithAgents: vi.fn(),
411411
listAgents: vi.fn(),
412412
getAgentWithDetails: vi.fn(),
413-
createAgent: vi.fn(),
413+
createOutboundAgent: vi.fn(),
414414
updateAgentConfiguration: vi.fn(),
415415
deleteAgent: vi.fn(),
416416
createTestCall: vi.fn(),

packages/features/calAIPhone/interfaces/AIPhoneService.interface.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ export interface AIPhoneServiceProvider<T extends AIPhoneServiceProviderType = A
285285
/**
286286
* Create a new agent
287287
*/
288-
createAgent(params: {
288+
createOutboundAgent(params: {
289289
name?: string;
290290
userId: number;
291291
teamId?: number;
@@ -301,6 +301,22 @@ export interface AIPhoneServiceProvider<T extends AIPhoneServiceProviderType = A
301301
message: string;
302302
}>;
303303

304+
/**
305+
* Create a new inbound agent
306+
*/
307+
createInboundAgent(params: {
308+
name?: string;
309+
phoneNumber: string;
310+
userId: number;
311+
teamId?: number;
312+
workflowStepId: number;
313+
userTimeZone: string;
314+
}): Promise<{
315+
id: string;
316+
providerAgentId: string;
317+
message: string;
318+
}>;
319+
304320
/**
305321
* Update agent configuration
306322
*/

packages/features/calAIPhone/promptTemplates.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export const DEFAULT_PROMPT_VALUE = `## You are helping user set up a call with
5353
- 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.
5454
- if availability does not exist, ask user to select another time range for the appointment, repeat this step 3.
5555
5. Confirm the date and time selected by user: \"Just to confirm, you want to book the appointment at ...\".
56-
6. Once confirmed, you can use {{NUMBER_TO_CALL}} as phone number for creating booking and call function book_appointment_{{eventTypeId}} to book the appointment.
56+
6. Once confirmed, you can use {{user_number}} if it is not unknown else ask user for phone number in international format and use it for creating booking if it is a required field and call function book_appointment_{{eventTypeId}} to book the appointment.
5757
- if booking returned booking detail, it means booking is successful, proceed to step 7.
5858
- if booking returned error message, let user know why the booking was not successful, and maybe start over with step 3.
5959
7. Inform the user booking is successful, and ask if user have any questions. Answer them if there are any.

0 commit comments

Comments
 (0)