Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
1babab2
feat: lang support
Udit-takkar Sep 16, 2025
8b20a3d
fix: type errors
Udit-takkar Sep 16, 2025
943fd25
Merge branch 'main' into feat/lang-dropdown
Udit-takkar Sep 16, 2025
a4727f4
feat: select voice agent
Udit-takkar Sep 16, 2025
865b768
refactor: address feedback
Udit-takkar Sep 16, 2025
06143f2
refactor: address feedback
Udit-takkar Sep 16, 2025
c7568d1
refactor: missing import
Udit-takkar Sep 16, 2025
4f27a1e
fix: types
Udit-takkar Sep 16, 2025
686d0f0
Merge branch 'main' into feat/inbound
Udit-takkar Sep 17, 2025
33afd46
feat: add inbound calls
Udit-takkar Sep 17, 2025
79b45d7
chore: formatting
Udit-takkar Sep 17, 2025
f5756f0
chore
Udit-takkar Sep 17, 2025
cfed93d
feat: finish inbound call
Udit-takkar Sep 17, 2025
9d8278b
chore: formatting
Udit-takkar Sep 18, 2025
264ac43
fix: update bug
Udit-takkar Sep 18, 2025
344c80e
fix: types
Udit-takkar Sep 18, 2025
66a3536
refactor: Agent Configuration Sheet (#23930)
Udit-takkar Sep 22, 2025
70160a6
refactor: improvements
Udit-takkar Sep 22, 2025
1ce3d99
refactor: improvements
Udit-takkar Sep 22, 2025
167d12f
fix: types
Udit-takkar Sep 22, 2025
fc619cc
fix: feedback
Udit-takkar Sep 22, 2025
e212717
chore:
Udit-takkar Sep 22, 2025
3ef5817
fix: feedback
Udit-takkar Sep 22, 2025
837dccd
fix: prompt
Udit-takkar Sep 22, 2025
fe75dc4
fix: review
Udit-takkar Sep 22, 2025
ca1e664
fix: review
Udit-takkar Sep 23, 2025
ac82d01
Merge branch 'main' into feat/inbound
Udit-takkar Sep 23, 2025
e9aee29
refactor: class
Udit-takkar Sep 23, 2025
3972e82
refactor: class
Udit-takkar Sep 23, 2025
1efe657
Merge branch 'main' into feat/inbound
Udit-takkar Sep 24, 2025
66370ad
Merge branch 'main' into feat/inbound
Udit-takkar Sep 26, 2025
ea71600
refactor: rename
Udit-takkar Sep 26, 2025
722a629
Update apps/web/public/static/locales/en/common.json
CarinaWolli Sep 26, 2025
3fa144b
Update apps/web/public/static/locales/en/common.json
CarinaWolli Sep 26, 2025
4e6724b
chore: update set value
Udit-takkar Sep 29, 2025
9db05d3
fix: remove index
Udit-takkar Sep 29, 2025
dea7312
fix: type error
Udit-takkar Sep 29, 2025
734e575
fix: update tetss
Udit-takkar Sep 29, 2025
2a8e7f7
Merge branch 'main' into feat/inbound
Udit-takkar Sep 29, 2025
a6d4260
fix: use logger
Udit-takkar Sep 29, 2025
9e2e8fb
Merge branch 'main' into feat/inbound
Udit-takkar Sep 29, 2025
50d3214
Merge branch 'main' into feat/inbound
Udit-takkar Oct 1, 2025
c3e19ee
refactor: don't use static
Udit-takkar Oct 1, 2025
fcc2250
fix: type
Udit-takkar Oct 1, 2025
ffd67cd
fix: schema
Udit-takkar Oct 6, 2025
52820a0
refactor:
Udit-takkar Oct 6, 2025
ba5525b
Merge branch 'main' into feat/inbound
Udit-takkar Oct 6, 2025
c3fbe08
Merge branch 'main' into feat/inbound
Udit-takkar Oct 8, 2025
9fdb933
Merge branch 'main' into feat/inbound
Udit-takkar Oct 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 31 additions & 29 deletions apps/web/app/api/webhooks/retell-ai/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import type { NextRequest } from "next/server";
import { Retell } from "retell-sdk";
import { describe, it, expect, vi, beforeEach } from "vitest";

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

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

const mockFindByPhoneNumber = vi.fn();
const mockFindByProviderAgentId = vi.fn();

vi.mock("@calcom/lib/server/repository/PrismaPhoneNumberRepository", () => ({
PrismaPhoneNumberRepository: {
findByPhoneNumber: vi.fn(),
},
PrismaPhoneNumberRepository: vi.fn().mockImplementation(() => ({
findByPhoneNumber: mockFindByPhoneNumber,
})),
}));

vi.mock("@calcom/lib/server/repository/PrismaAgentRepository", () => ({
PrismaAgentRepository: {
findByProviderAgentId: vi.fn(),
},
PrismaAgentRepository: vi.fn().mockImplementation(() => ({
findByProviderAgentId: mockFindByProviderAgentId,
})),
}));

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

vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
mockFindByPhoneNumber.mockResolvedValue(mockPhoneNumber);

mockHasAvailableCredits.mockResolvedValue(true);
mockChargeCredits.mockResolvedValue(undefined);
Expand Down Expand Up @@ -255,7 +256,7 @@ describe("Retell AI Webhook Handler", () => {
user: null,
};

vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockTeamPhoneNumber);
mockFindByPhoneNumber.mockResolvedValue(mockTeamPhoneNumber);

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

expect(response.status).toBe(200);
expect(PrismaPhoneNumberRepository.findByPhoneNumber).not.toHaveBeenCalled();
expect(mockFindByPhoneNumber).not.toHaveBeenCalled();
});

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

const body: RetellWebhookBody = {
event: "call_analyzed",
Expand Down Expand Up @@ -365,7 +366,7 @@ describe("Retell AI Webhook Handler", () => {
team: null,
};

vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
mockFindByPhoneNumber.mockResolvedValue(mockPhoneNumber);

mockHasAvailableCredits.mockResolvedValue(false);

Expand Down Expand Up @@ -440,7 +441,7 @@ describe("Retell AI Webhook Handler", () => {
team: null,
};

vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
mockFindByPhoneNumber.mockResolvedValue(mockPhoneNumber);
mockHasAvailableCredits.mockResolvedValue(true);
mockChargeCredits.mockResolvedValue(undefined);

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

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

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

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

// Mock chargeCredits to throw an error
mockChargeCredits.mockRejectedValue(new Error("Credit service error"));
Expand Down Expand Up @@ -695,7 +696,7 @@ describe("Retell AI Webhook Handler", () => {
describe("Web Call Tests", () => {
const mockAgent: Pick<
Agent,
"id" | "name" | "providerAgentId" | "enabled" | "userId" | "teamId" | "createdAt" | "updatedAt"
"id" | "name" | "providerAgentId" | "enabled" | "userId" | "teamId" | "createdAt" | "updatedAt" | "inboundEventTypeId"
> = {
id: "agent-123",
name: "Test Agent",
Expand All @@ -705,6 +706,7 @@ describe("Retell AI Webhook Handler", () => {
teamId: null,
createdAt: new Date(),
updatedAt: new Date(),
inboundEventTypeId: null,
};

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

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

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

expect(PrismaAgentRepository.findByProviderAgentId).toHaveBeenCalledWith({
expect(mockFindByProviderAgentId).toHaveBeenCalledWith({
providerAgentId: "agent_5e3e0d29d692172c2c24d8f9a7",
});

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

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

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

it("should handle web call without from_number", async () => {
vi.mocked(PrismaAgentRepository.findByProviderAgentId).mockResolvedValue(mockAgent);
mockFindByProviderAgentId.mockResolvedValue(mockAgent);
mockChargeCredits.mockResolvedValue(undefined);

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

expect(response.status).toBe(200);
expect(PrismaAgentRepository.findByProviderAgentId).toHaveBeenCalled();
expect(PrismaPhoneNumberRepository.findByPhoneNumber).not.toHaveBeenCalled();
expect(mockFindByProviderAgentId).toHaveBeenCalled();
expect(mockFindByPhoneNumber).not.toHaveBeenCalled();
expect(mockChargeCredits).toHaveBeenCalled();
});

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

expect(response.status).toBe(200);
expect(PrismaAgentRepository.findByProviderAgentId).not.toHaveBeenCalled();
expect(mockFindByProviderAgentId).not.toHaveBeenCalled();
expect(mockChargeCredits).not.toHaveBeenCalled();
});

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

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

expect(response.status).toBe(200);
expect(PrismaAgentRepository.findByProviderAgentId).toHaveBeenCalledWith({
expect(mockFindByProviderAgentId).toHaveBeenCalledWith({
providerAgentId: "non-existent-agent",
});
expect(mockChargeCredits).not.toHaveBeenCalled();
Expand Down
14 changes: 8 additions & 6 deletions apps/web/app/api/webhooks/retell-ai/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { PrismaAgentRepository } from "@calcom/lib/server/repository/PrismaAgentRepository";
import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository";
import prisma from "@calcom/prisma";
import { CreditUsageType } from "@calcom/prisma/enums";

const log = logger.getSubLogger({ prefix: ["retell-ai-webhook"] });
Expand Down Expand Up @@ -133,7 +134,7 @@ async function handleCallAnalyzed(callData: any) {
);
return {
success: true,
message: `Invalid or missing call_cost.total_duration_seconds for call ${call_id}`
message: `Invalid or missing call_cost.total_duration_seconds for call ${call_id}`,
};
}

Expand All @@ -146,29 +147,30 @@ async function handleCallAnalyzed(callData: any) {
log.error(`Web call ${call_id} missing agent_id, cannot charge credits`);
return {
success: false,
message: `Web call ${call_id} missing agent_id, cannot charge credits`
message: `Web call ${call_id} missing agent_id, cannot charge credits`,
};
}

const agent = await PrismaAgentRepository.findByProviderAgentId({
const agentRepo = new PrismaAgentRepository(prisma);
const agent = await agentRepo.findByProviderAgentId({
providerAgentId: agent_id,
});

if (!agent) {
log.error(`No agent found for providerAgentId ${agent_id}, call ${call_id}`);
return {
success: false,
message: `No agent found for providerAgentId ${agent_id}, call ${call_id}`
message: `No agent found for providerAgentId ${agent_id}, call ${call_id}`,
};
}


userId = agent.userId ?? undefined;
teamId = agent.teamId ?? undefined;

log.info(`Processing web call ${call_id} for agent ${agent_id}, user ${userId}, team ${teamId}`);
} else {
const phoneNumber = await PrismaPhoneNumberRepository.findByPhoneNumber({
const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
const phoneNumber = await phoneNumberRepo.findByPhoneNumber({
phoneNumber: from_number,
});

Expand Down
5 changes: 5 additions & 0 deletions apps/web/public/icons/sprite.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 17 additions & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,8 @@
"register_now": "Register now",
"register": "Register",
"page_doesnt_exist": "This page does not exist.",
"no_active_phone_number_available": "No active phone number available",
"workflow_step_not_configured": "Workflow step not configured",
"check_spelling_mistakes_or_go_back": "Check for spelling mistakes or go back to the previous page.",
"404_page_not_found": "404: This page could not be found.",
"booker_event_not_found": "We could not find the event you are trying to book.",
Expand Down Expand Up @@ -837,6 +839,7 @@
"please_enter_phone_number": "Please enter a phone number",
"agent_updated_successfully": "Agent updated successfully",
"agent_created_successfully": "Agent created successfully",
"agent_event_type_updated_successfully": "Event type added successfully",
"phone_number_unsubscribed_successfully": "Phone number unsubscribed successfully",
"general_prompt_description": "This prompt defines the agent's role and primary objectives",
"prompt": "Prompt",
Expand Down Expand Up @@ -3756,5 +3759,19 @@
"voice_id": "Voice ID",
"use_voice": "Use Voice",
"current_voice": "Current Voice",
"setup_incoming_agent": "Set up incoming agent",
"setup_incoming_agent_description": "Configure an AI agent to handle incoming calls on your phone number",
"connect_a_phone_number_first": "Connect a Phone Number first",
"setup_agent_for_incoming_calls": "Set up Agent for incoming calls",
"configure_agent_to_handle_incoming_calls": "Configure agent to handle incoming calls",
"incoming_calls": "Incoming Calls",
"outgoing_calls": "Outgoing Calls",
"inbound_agent_setup_success": "Inbound agent setup successful",
"inbound_agent_configured": "Inbound agent configured",
"setup_inbound_agent": "Set up Inbound Agent",
"please_select_event_type_first": "Please select an event type first",
"edit_configuration": "Edit Configuration",
"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.",
"setup_inbound_agent_for_incoming_calls": "Set up inbound agent for incoming calls",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
6 changes: 3 additions & 3 deletions packages/features/calAIPhone/AIPhoneServiceRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ describe("AIPhoneServiceRegistry", () => {
updatePhoneNumberWithAgents: vi.fn().mockResolvedValue({ message: "test-message" }),
listAgents: vi.fn().mockResolvedValue({ totalCount: 1, filtered: [] }),
getAgentWithDetails: vi.fn().mockResolvedValue({ agent_id: "test-agent" }),
createAgent: vi
createOutboundAgent: vi
.fn()
.mockResolvedValue({ id: "test-id", providerAgentId: "test-provider-id", message: "test-message" }),
updateAgentConfiguration: vi.fn().mockResolvedValue({ message: "test-message" }),
Expand Down Expand Up @@ -285,7 +285,7 @@ describe("createAIPhoneServiceProvider", () => {
updatePhoneNumberWithAgents: vi.fn(),
listAgents: vi.fn(),
getAgentWithDetails: vi.fn(),
createAgent: vi.fn(),
createOutboundAgent: vi.fn(),
updateAgentConfiguration: vi.fn(),
deleteAgent: vi.fn(),
createTestCall: vi.fn(),
Expand Down Expand Up @@ -410,7 +410,7 @@ describe("createDefaultAIPhoneServiceProvider", () => {
updatePhoneNumberWithAgents: vi.fn(),
listAgents: vi.fn(),
getAgentWithDetails: vi.fn(),
createAgent: vi.fn(),
createOutboundAgent: vi.fn(),
updateAgentConfiguration: vi.fn(),
deleteAgent: vi.fn(),
createTestCall: vi.fn(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ export interface AIPhoneServiceProvider<T extends AIPhoneServiceProviderType = A
/**
* Create a new agent
*/
createAgent(params: {
createOutboundAgent(params: {
name?: string;
userId: number;
teamId?: number;
Expand All @@ -301,6 +301,22 @@ export interface AIPhoneServiceProvider<T extends AIPhoneServiceProviderType = A
message: string;
}>;

/**
* Create a new inbound agent
*/
createInboundAgent(params: {
name?: string;
phoneNumber: string;
userId: number;
teamId?: number;
workflowStepId: number;
userTimeZone: string;
}): Promise<{
id: string;
providerAgentId: string;
message: string;
}>;

/**
* Update agent configuration
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/features/calAIPhone/promptTemplates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const DEFAULT_PROMPT_VALUE = `## You are helping user set up a call with
- 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, you can use {{NUMBER_TO_CALL}} as phone number for creating booking and call function book_appointment_{{eventTypeId}} to book the appointment.
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.
- 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.
Expand Down
Loading
Loading