feat: inbound calls in cal ai (#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 (#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>
This commit is contained in:
Udit Takkar
2025-10-09 11:40:37 +01:00
committed by GitHub
co-authored by Carina Wollendorfer
parent bec49ef417
commit aa48e72c12
74 changed files with 2980 additions and 1680 deletions
@@ -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";
@@ -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", () => ({
@@ -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);
@@ -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);
@@ -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",
@@ -365,7 +366,7 @@ describe("Retell AI Webhook Handler", () => {
team: null,
};
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
mockFindByPhoneNumber.mockResolvedValue(mockPhoneNumber);
mockHasAvailableCredits.mockResolvedValue(false);
@@ -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);
@@ -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);
@@ -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 = {
@@ -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",
@@ -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"));
@@ -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",
@@ -705,6 +706,7 @@ describe("Retell AI Webhook Handler", () => {
teamId: null,
createdAt: new Date(),
updatedAt: new Date(),
inboundEventTypeId: null,
};
beforeEach(() => {
@@ -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 = {
@@ -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",
});
@@ -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 = {
@@ -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 = {
@@ -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();
});
@@ -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",
@@ -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();
+8 -6
View File
@@ -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"] });
@@ -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}`,
};
}
@@ -146,11 +147,12 @@ 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,
});
@@ -158,17 +160,17 @@ async function handleCallAnalyzed(callData: any) {
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,
});
+5
View File
@@ -525,6 +525,11 @@
<path d="M14.05 2a9 9 0 0 1 8 7.94" ></path>
<path d="M14.05 6A5 5 0 0 1 18 10" ></path>
</symbol>
<symbol class="lucide lucide-phone-incoming" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="phone-incoming">
<polyline points="16 2 16 8 22 8" ></polyline>
<line x1="22" x2="16" y1="2" y2="8" ></line>
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" ></path>
</symbol>
<symbol class="lucide lucide-phone-off" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="phone-off">
<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91" ></path>
<line x1="22" x2="2" y1="2" y2="22" ></line>

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 55 KiB

@@ -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.",
@@ -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",
@@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -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" }),
@@ -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(),
@@ -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(),
@@ -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;
@@ -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
*/
@@ -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.
@@ -1,5 +1,5 @@
import { PrismaAgentRepository } from "@calcom/lib/server/repository/PrismaAgentRepository";
import prisma from "@calcom/prisma";
import type {
AgentRepositoryInterface,
AgentData,
@@ -13,18 +13,21 @@ import type {
*/
export class PrismaAgentRepositoryAdapter implements AgentRepositoryInterface {
async canManageTeamResources(params: { userId: number; teamId: number }): Promise<boolean> {
return await PrismaAgentRepository.canManageTeamResources(params);
const agentRepo = new PrismaAgentRepository(prisma);
return await agentRepo.canManageTeamResources(params);
}
async findByIdWithUserAccess(params: { agentId: string; userId: number }): Promise<AgentData | null> {
return await PrismaAgentRepository.findByIdWithUserAccess(params);
const agentRepo = new PrismaAgentRepository(prisma);
return await agentRepo.findByIdWithUserAccess(params);
}
async findByProviderAgentIdWithUserAccess(params: {
providerAgentId: string;
userId: number;
}): Promise<AgentData | null> {
return await PrismaAgentRepository.findByProviderAgentIdWithUserAccess(params);
const agentRepo = new PrismaAgentRepository(prisma);
return await agentRepo.findByProviderAgentIdWithUserAccess(params);
}
async findManyWithUserAccess(params: {
@@ -32,7 +35,8 @@ export class PrismaAgentRepositoryAdapter implements AgentRepositoryInterface {
teamId?: number;
scope?: "personal" | "team" | "all";
}): Promise<AgentWithDetailsData[]> {
return await PrismaAgentRepository.findManyWithUserAccess(params);
const agentRepo = new PrismaAgentRepository(prisma);
return await agentRepo.findManyWithUserAccess(params);
}
async findByIdWithUserAccessAndDetails(params: {
@@ -40,7 +44,8 @@ export class PrismaAgentRepositoryAdapter implements AgentRepositoryInterface {
userId: number;
teamId?: number;
}): Promise<AgentWithDetailsData | null> {
return await PrismaAgentRepository.findByIdWithUserAccessAndDetails(params);
const agentRepo = new PrismaAgentRepository(prisma);
return await agentRepo.findByIdWithUserAccessAndDetails(params);
}
async create(params: {
@@ -49,7 +54,8 @@ export class PrismaAgentRepositoryAdapter implements AgentRepositoryInterface {
userId: number;
teamId?: number;
}): Promise<AgentData> {
return await PrismaAgentRepository.create(params);
const agentRepo = new PrismaAgentRepository(prisma);
return await agentRepo.create(params);
}
async findByIdWithAdminAccess(params: {
@@ -57,21 +63,30 @@ export class PrismaAgentRepositoryAdapter implements AgentRepositoryInterface {
userId: number;
teamId?: number;
}): Promise<AgentData | null> {
return await PrismaAgentRepository.findByIdWithAdminAccess(params);
const agentRepo = new PrismaAgentRepository(prisma);
return await agentRepo.findByIdWithAdminAccess(params);
}
async findByIdWithCallAccess(params: {
id: string;
userId: number;
}): Promise<AgentWithPhoneNumbersData | null> {
return await PrismaAgentRepository.findByIdWithCallAccess(params);
const agentRepo = new PrismaAgentRepository(prisma);
return await agentRepo.findByIdWithCallAccess(params);
}
async delete(params: { id: string }): Promise<void> {
await PrismaAgentRepository.delete(params);
const agentRepo = new PrismaAgentRepository(prisma);
await agentRepo.delete(params);
}
async linkToWorkflowStep(params: { workflowStepId: number; agentId: string }): Promise<void> {
await PrismaAgentRepository.linkToWorkflowStep(params);
async linkOutboundAgentToWorkflow(params: { workflowStepId: number; agentId: string }): Promise<void> {
const agentRepo = new PrismaAgentRepository(prisma);
await agentRepo.linkOutboundAgentToWorkflow(params);
}
async linkInboundAgentToWorkflow(params: { workflowStepId: number; agentId: string }): Promise<void> {
const agentRepo = new PrismaAgentRepository(prisma);
await agentRepo.linkInboundAgentToWorkflow(params);
}
}
@@ -1,4 +1,5 @@
import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository";
import prisma from "@calcom/prisma";
import type { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums";
import type {
@@ -15,7 +16,8 @@ export class PrismaPhoneNumberRepositoryAdapter implements PhoneNumberRepository
phoneNumber: string;
userId: number;
}): Promise<PhoneNumberData | null> {
return await PrismaPhoneNumberRepository.findByPhoneNumberAndUserId(params);
const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
return await phoneNumberRepo.findByPhoneNumberAndUserId(params);
}
async findByPhoneNumberAndTeamId(params: {
@@ -23,11 +25,13 @@ export class PrismaPhoneNumberRepositoryAdapter implements PhoneNumberRepository
teamId: number;
userId: number;
}): Promise<PhoneNumberData | null> {
return await PrismaPhoneNumberRepository.findByPhoneNumberAndTeamId(params);
const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
return await phoneNumberRepo.findByPhoneNumberAndTeamId(params);
}
async findByIdAndUserId(params: { id: number; userId: number }): Promise<PhoneNumberData | null> {
return await PrismaPhoneNumberRepository.findByIdAndUserId(params);
const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
return await phoneNumberRepo.findByIdAndUserId(params);
}
async findByIdWithTeamAccess(params: {
@@ -35,7 +39,8 @@ export class PrismaPhoneNumberRepositoryAdapter implements PhoneNumberRepository
teamId: number;
userId: number;
}): Promise<PhoneNumberData | null> {
return await PrismaPhoneNumberRepository.findByIdWithTeamAccess(params);
const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
return await phoneNumberRepo.findByIdWithTeamAccess(params);
}
async createPhoneNumber(params: {
@@ -45,6 +50,7 @@ export class PrismaPhoneNumberRepositoryAdapter implements PhoneNumberRepository
teamId?: number;
outboundAgentId?: string | null;
}): Promise<PhoneNumberData> {
const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
const createParams = {
phoneNumber: params.phoneNumber,
provider: params.provider,
@@ -52,19 +58,21 @@ export class PrismaPhoneNumberRepositoryAdapter implements PhoneNumberRepository
teamId: params.teamId,
};
return await PrismaPhoneNumberRepository.createPhoneNumber(createParams);
return await phoneNumberRepo.createPhoneNumber(createParams);
}
async deletePhoneNumber(params: { phoneNumber: string }): Promise<void> {
await PrismaPhoneNumberRepository.deletePhoneNumber(params);
const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
await phoneNumberRepo.deletePhoneNumber(params);
}
async updateSubscriptionStatus(params: {
id: number;
subscriptionStatus: PhoneNumberSubscriptionStatus;
disconnectOutboundAgent?: boolean;
disconnectAgents?: boolean;
}): Promise<void> {
await PrismaPhoneNumberRepository.updateSubscriptionStatus(params);
const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
await phoneNumberRepo.updateSubscriptionStatus(params);
}
async updateAgents(params: {
@@ -72,10 +80,23 @@ export class PrismaPhoneNumberRepositoryAdapter implements PhoneNumberRepository
inboundProviderAgentId?: string | null;
outboundProviderAgentId?: string | null;
}): Promise<void> {
await PrismaPhoneNumberRepository.updateAgents({
const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
await phoneNumberRepo.updateAgents({
id: params.id,
inboundProviderAgentId: params.inboundProviderAgentId,
outboundProviderAgentId: params.outboundProviderAgentId,
});
}
async updateInboundAgentId(params: { id: number; agentId: string }): Promise<{ count: number }> {
const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
return await phoneNumberRepo.updateInboundAgentId(params);
}
async findInboundAgentIdByPhoneNumberId(params: {
phoneNumberId: number;
}): Promise<{ inboundAgentId: string | null } | null> {
const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
return await phoneNumberRepo.findInboundAgentIdByPhoneNumberId(params);
}
}
@@ -68,7 +68,12 @@ export interface AgentRepositoryInterface {
/**
* Link agent to workflow step
*/
linkToWorkflowStep(params: { workflowStepId: number; agentId: string }): Promise<void>;
linkOutboundAgentToWorkflow(params: { workflowStepId: number; agentId: string }): Promise<void>;
/**
* Link inbound agent to workflow
*/
linkInboundAgentToWorkflow(params: { workflowStepId: number; agentId: string }): Promise<void>;
}
/**
@@ -81,6 +86,7 @@ export interface AgentData {
enabled: boolean;
userId: number | null;
teamId: number | null;
inboundEventTypeId?: number | null;
createdAt: Date;
updatedAt: Date;
}
@@ -63,7 +63,7 @@ export interface PhoneNumberRepositoryInterface {
updateSubscriptionStatus(params: {
id: number;
subscriptionStatus: PhoneNumberSubscriptionStatus;
disconnectOutboundAgent?: boolean;
disconnectAgents?: boolean;
}): Promise<void>;
/**
@@ -74,6 +74,16 @@ export interface PhoneNumberRepositoryInterface {
inboundProviderAgentId?: string | null;
outboundProviderAgentId?: string | null;
}): Promise<void>;
/**
* Conditionally set inbound agent only if currently unset (atomic operation to prevent race conditions)
*/
updateInboundAgentId(params: { id: number; agentId: string }): Promise<{ count: number }>;
/**
* Find inbound agent by phone number ID
*/
findInboundAgentIdByPhoneNumberId(params: { phoneNumberId: number }): Promise<{ inboundAgentId: string | null } | null>;
}
/**
@@ -42,7 +42,7 @@ function createMockRetellAIService(overrides: Partial<RetellAIService> = {}): Re
updatePhoneNumberWithAgents: vi.fn(),
listAgents: vi.fn(),
getAgentWithDetails: vi.fn(),
createAgent: vi.fn(),
createOutboundAgent: vi.fn(),
updateAgentConfiguration: vi.fn(),
deleteAgent: vi.fn(),
createTestCall: vi.fn(),
@@ -67,7 +67,7 @@ describe("RetellAIPhoneServiceProvider", () => {
deleteLLM: vi.fn().mockResolvedValue(undefined),
// Agent operations
createAgent: vi.fn().mockResolvedValue({ agent_id: "test-agent-id" }),
createOutboundAgent: vi.fn().mockResolvedValue({ agent_id: "test-agent-id" }),
getAgent: vi.fn().mockResolvedValue({ agent_id: "test-agent-id", agent_name: "Test Agent" }),
updateAgent: vi.fn().mockResolvedValue({ agent_id: "test-agent-id" }),
deleteAgent: vi.fn().mockResolvedValue(undefined),
@@ -94,7 +94,7 @@ describe("RetellAIPhoneServiceProvider", () => {
findByIdWithAdminAccess: vi.fn(),
findByIdWithCallAccess: vi.fn(),
delete: vi.fn(),
linkToWorkflowStep: vi.fn(),
linkOutboundAgentToWorkflow: vi.fn(),
} as unknown as AgentRepositoryInterface;
// Mock phone number repository
@@ -210,7 +210,7 @@ export class RetellAIPhoneServiceProvider
return await this.service.getAgentWithDetails(params);
}
async createAgent(params: {
async createOutboundAgent(params: {
name?: string;
userId: number;
teamId?: number;
@@ -225,7 +225,27 @@ export class RetellAIPhoneServiceProvider
providerAgentId: string;
message: string;
}> {
const result = await this.service.createAgent(params);
const result = await this.service.createOutboundAgent(params);
return {
id: result.id,
providerAgentId: result.providerAgentId,
message: result.message,
};
}
async createInboundAgent(params: {
name?: string;
phoneNumber: string;
userId: number;
teamId?: number;
workflowStepId: number;
userTimeZone: string;
}): Promise<{
id: string;
providerAgentId: string;
message: string;
}> {
const result = await this.service.createInboundAgent(params);
return {
id: result.id,
providerAgentId: result.providerAgentId,
@@ -25,7 +25,7 @@ vi.mock("./RetellSDKClient", () => ({
getLLM: vi.fn(),
updateLLM: vi.fn(),
deleteLLM: vi.fn(),
createAgent: vi.fn(),
createOutboundAgent: vi.fn(),
getAgent: vi.fn(),
updateAgent: vi.fn(),
deleteAgent: vi.fn(),
@@ -72,7 +72,7 @@ vi.mock("../adapters/PrismaAgentRepositoryAdapter", () => ({
findByIdWithAdminAccess: vi.fn(),
findByIdWithCallAccess: vi.fn(),
delete: vi.fn(),
linkToWorkflowStep: vi.fn(),
linkOutboundAgentToWorkflow: vi.fn(),
})),
}));
@@ -11,30 +11,6 @@ import { RetellAIError } from "./errors";
import { createMockDatabaseAgent } from "./services/__tests__/test-utils";
import type { RetellAIRepository } from "./types";
vi.mock("@calcom/lib/server/repository/PrismaPhoneNumberRepository", () => ({
PrismaPhoneNumberRepository: {
createPhoneNumber: vi.fn(),
findByPhoneNumberAndUserId: vi.fn(),
deletePhoneNumber: vi.fn(),
updateAgents: vi.fn(),
updateSubscriptionStatus: vi.fn(),
findByIdAndUserId: vi.fn(),
},
}));
vi.mock("@calcom/lib/server/repository/PrismaAgentRepository", () => ({
PrismaAgentRepository: {
findByIdWithUserAccess: vi.fn(),
findByProviderAgentIdWithUserAccess: vi.fn(),
findManyWithUserAccess: vi.fn(),
findByIdWithUserAccessAndDetails: vi.fn(),
canManageTeamResources: vi.fn(),
create: vi.fn(),
linkToWorkflowStep: vi.fn(),
findByIdWithAdminAccess: vi.fn(),
delete: vi.fn(),
findByIdWithCallAccess: vi.fn(),
},
}));
vi.mock("@calcom/app-store/stripepayment/lib/customer", () => ({
getStripeCustomerIdFromUserId: vi.fn(),
@@ -104,7 +80,7 @@ describe("RetellAIService", () => {
getLLM: vi.fn(),
updateLLM: vi.fn(),
deleteLLM: vi.fn(),
createAgent: vi.fn(),
createOutboundAgent: vi.fn(),
getAgent: vi.fn(),
updateAgent: vi.fn(),
deleteAgent: vi.fn(),
@@ -128,7 +104,7 @@ describe("RetellAIService", () => {
findByIdWithAdminAccess: vi.fn(),
findByIdWithCallAccess: vi.fn(),
delete: vi.fn(),
linkToWorkflowStep: vi.fn(),
linkOutboundAgentToWorkflow: vi.fn(),
};
mockAgentRepository = agentRepository as unknown as AgentRepositoryInterface;
@@ -192,7 +168,7 @@ describe("RetellAIService", () => {
const mockLLM = { llm_id: "llm-123" };
const mockAgent = { agent_id: "agent-123" };
mockRepository.createLLM.mockResolvedValue(mockLLM);
mockRepository.createAgent.mockResolvedValue(mockAgent);
mockRepository.createOutboundAgent.mockResolvedValue(mockAgent);
const result = await service.setupAIConfiguration({});
@@ -215,7 +191,7 @@ describe("RetellAIService", () => {
const mockAgent = { agent_id: "agent-123" };
mockRepository.createLLM.mockResolvedValue(mockLLM);
mockRepository.createAgent.mockResolvedValue(mockAgent);
mockRepository.createOutboundAgent.mockResolvedValue(mockAgent);
await service.setupAIConfiguration({
calApiKey: "cal-key",
@@ -566,7 +542,7 @@ describe("RetellAIService", () => {
expect(mockRepository.updateLLM).toHaveBeenCalledWith("llm-123", {
general_prompt: "Updated prompt",
begin_message: "Updated message",
general_tools: null,
general_tools: undefined,
});
});
});
@@ -732,7 +708,7 @@ describe("RetellAIService", () => {
expect(mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenCalledWith({
id: 1,
subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED,
disconnectOutboundAgent: true,
disconnectAgents: true,
});
});
});
@@ -813,10 +789,10 @@ describe("RetellAIService", () => {
});
});
describe("createAgent", () => {
describe("createOutboundAgent", () => {
it("should create agent successfully", async () => {
mockRepository.createLLM.mockResolvedValue({ llm_id: "llm-123" });
mockRepository.createAgent.mockResolvedValue({ agent_id: "agent-123" });
mockRepository.createOutboundAgent.mockResolvedValue({ agent_id: "agent-123" });
mockAgentRepository.create.mockResolvedValue({
id: "db-agent-123",
name: "Test Agent",
@@ -828,7 +804,7 @@ describe("RetellAIService", () => {
updatedAt: new Date(),
});
const result = await service.createAgent({
const result = await service.createOutboundAgent({
name: "Test Agent",
userId: 1,
userTimeZone: "America/New_York",
@@ -996,7 +972,6 @@ describe("RetellAIService", () => {
it("should throw error if agent not found", async () => {
const { CreditService } = await import("@calcom/features/ee/billing/credit-service");
const { checkRateLimitAndThrowError } = await import("@calcom/lib/checkRateLimitAndThrowError");
const { PrismaAgentRepository } = await import("@calcom/lib/server/repository/PrismaAgentRepository");
// Mock sufficient credits
const mockHasAvailableCredits = vi.fn().mockResolvedValue(true);
@@ -1005,7 +980,7 @@ describe("RetellAIService", () => {
}));
(checkRateLimitAndThrowError as any).mockResolvedValue(undefined);
(PrismaAgentRepository.findByIdWithCallAccess as any).mockResolvedValue(null);
mockAgentRepository.findByIdWithCallAccess.mockResolvedValue(null);
await expect(
service.createTestCall({
@@ -41,17 +41,27 @@ export class RetellAIService {
private phoneNumberRepository: PhoneNumberRepositoryInterface,
private transactionManager: TransactionInterface
) {
this.aiConfigurationService = new AIConfigurationService(repository);
this.agentService = new AgentService(repository, agentRepository);
this.billingService = new BillingService(phoneNumberRepository, repository);
this.callService = new CallService(repository, agentRepository);
this.phoneNumberService = new PhoneNumberService(
repository,
this.aiConfigurationService = new AIConfigurationService({ retellRepository: repository });
this.agentService = new AgentService({
retellRepository: repository,
agentRepository,
phoneNumberRepository,
transactionManager
);
this.voiceService = new VoiceService(repository);
});
this.billingService = new BillingService({
phoneNumberRepository,
retellRepository: repository,
});
this.callService = new CallService({
retellRepository: repository,
agentRepository,
});
this.phoneNumberService = new PhoneNumberService({
retellRepository: repository,
agentRepository,
phoneNumberRepository,
transactionManager,
});
this.voiceService = new VoiceService({ retellRepository: repository });
// Inject RetellAIService reference into CallService
this.callService.setRetellAIService(this);
@@ -141,7 +151,7 @@ export class RetellAIService {
return this.agentService.getAgentWithDetails(params);
}
async createAgent(params: {
async createOutboundAgent(params: {
name?: string;
userId: number;
teamId?: number;
@@ -151,7 +161,7 @@ export class RetellAIService {
generalTools?: RetellLLMGeneralTools;
userTimeZone: string;
}) {
return this.agentService.createAgent({
return this.agentService.createOutboundAgent({
...params,
setupAIConfiguration: () =>
this.setupAIConfiguration({
@@ -165,6 +175,20 @@ export class RetellAIService {
});
}
async createInboundAgent(params: {
name?: string;
phoneNumber: string;
userId: number;
teamId?: number;
workflowStepId: number;
userTimeZone: string;
}) {
return this.agentService.createInboundAgent({
...params,
aiConfigurationService: this.aiConfigurationService,
});
}
async updateAgentConfiguration(params: {
id: string;
userId: number;
@@ -102,7 +102,7 @@ export class RetellAIServiceMapper {
return {
general_prompt: data.general_prompt,
begin_message: data.begin_message,
general_tools: data.general_tools ?? null,
general_tools: data.general_tools ?? undefined,
};
}
@@ -173,6 +173,7 @@ export class RetellAIServiceMapper {
enabled: agent.enabled,
userId: agent.userId,
teamId: agent.teamId,
inboundEventTypeId: agent.inboundEventTypeId,
outboundPhoneNumbers: agent.outboundPhoneNumbers,
retellData: {
agentId: retellAgent.agent_id,
@@ -218,7 +218,7 @@ describe("RetellSDKClient", () => {
client = new RetellSDKClient(mockLogger);
});
describe("createAgent", () => {
describe("createOutboundAgent", () => {
it("should create agent", async () => {
const mockRequest: CreateAgentRequest = {
agent_name: "Test Agent",
@@ -229,7 +229,7 @@ describe("RetellSDKClient", () => {
mockRetellInstance.agent.create.mockResolvedValue(mockResponse);
const result = await client.createAgent(mockRequest);
const result = await client.createOutboundAgent(mockRequest);
expect(mockRetellInstance.agent.create).toHaveBeenCalledWith(mockRequest);
expect(result).toEqual(mockResponse);
@@ -95,7 +95,7 @@ export class RetellSDKClient implements RetellAIRepository {
}
}
async createAgent(data: CreateAgentRequest): Promise<RetellAgent> {
async createOutboundAgent(data: CreateAgentRequest): Promise<RetellAgent> {
this.logger.info("Creating agent via SDK", {
agentName: data.agent_name,
});
@@ -14,10 +14,13 @@ import type {
DeletionResult,
} from "../types";
type Dependencies = {
retellRepository: RetellAIRepository;
};
export class AIConfigurationService {
private logger = logger.getSubLogger({ prefix: ["AIConfigurationService"] });
constructor(private retellRepository: RetellAIRepository) {}
constructor(private deps: Dependencies) {}
async setupAIConfiguration(config: AIConfigurationSetup): Promise<{ llmId: string; agentId: string }> {
let llmId: string | null = null;
@@ -35,19 +38,19 @@ export class AIConfigurationService {
);
// Step 1: Create LLM
const llm = await this.retellRepository.createLLM(llmRequest);
const llm = await this.deps.retellRepository.createLLM(llmRequest);
llmId = llm.llm_id;
// Step 2: Create Agent
const agentRequest = RetellAIServiceMapper.mapToCreateAgentRequest(llm.llm_id, config.eventTypeId);
const agent = await this.retellRepository.createAgent(agentRequest);
const agent = await this.deps.retellRepository.createOutboundAgent(agentRequest);
return { llmId: llm.llm_id, agentId: agent.agent_id };
} catch (error) {
// Compensation: Clean up LLM if agent creation failed
if (llmId) {
try {
await this.retellRepository.deleteLLM(llmId);
await this.deps.retellRepository.deleteLLM(llmId);
this.logger.info(`Successfully cleaned up orphaned LLM ${llmId} after setup failure`);
} catch (cleanupError) {
const errorMessage = `Failed to cleanup LLM ${llmId} after AI configuration setup failure. This will cause billing charges.`;
@@ -86,7 +89,7 @@ export class AIConfigurationService {
if (config.agentId) {
try {
this.logger.info(`Attempting to delete agent: ${config.agentId}`);
await this.retellRepository.deleteAgent(config.agentId);
await this.deps.retellRepository.deleteAgent(config.agentId);
result.deleted.agent = true;
this.logger.info(`Successfully deleted agent: ${config.agentId}`);
} catch (error) {
@@ -108,7 +111,7 @@ export class AIConfigurationService {
if (config.llmId) {
try {
this.logger.info(`Attempting to delete LLM: ${config.llmId}`);
await this.retellRepository.deleteLLM(config.llmId);
await this.deps.retellRepository.deleteLLM(config.llmId);
result.deleted.llm = true;
this.logger.info(`Successfully deleted LLM: ${config.llmId}`);
} catch (error) {
@@ -152,20 +155,25 @@ export class AIConfigurationService {
try {
const updateRequest = RetellAIServiceMapper.mapToUpdateLLMRequest(data);
return await this.retellRepository.updateLLM(llmId, updateRequest);
return await this.deps.retellRepository.updateLLM(llmId, updateRequest);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to update LLM configuration for ${llmId}: ${errorMessage}`);
}
}
async setupInboundAIConfiguration(): Promise<{ llmId: string; agentId: string }> {
return this.setupAIConfiguration({});
}
async getLLMDetails(llmId: string): Promise<AIPhoneServiceModel<AIPhoneServiceProviderType.RETELL_AI>> {
if (!llmId?.trim()) {
throw new Error("LLM ID is required and cannot be empty");
}
try {
return await this.retellRepository.getLLM(llmId);
return await this.deps.retellRepository.getLLM(llmId);
} catch (error) {
this.logger.error(`Failed to get LLM details for '${llmId}'`, {
llmId,
@@ -1,5 +1,7 @@
import { isValidPhoneNumber } from "libphonenumber-js";
import { v4 as uuidv4 } from "uuid";
import { replaceEventTypePlaceholders } from "@calcom/features/ee/workflows/components/agent-configuration/utils/promptUtils";
import { RETELL_AI_TEST_MODE, RETELL_AI_TEST_EVENT_TYPE_MAP } from "@calcom/lib/constants";
import { timeZoneSchema } from "@calcom/lib/dayjs/timeZone.schema";
import { HttpError } from "@calcom/lib/http-error";
@@ -14,17 +16,20 @@ import type {
AIPhoneServiceTools,
} from "../../../interfaces/AIPhoneService.interface";
import type { AgentRepositoryInterface } from "../../interfaces/AgentRepositoryInterface";
import type { PhoneNumberRepositoryInterface } from "../../interfaces/PhoneNumberRepositoryInterface";
import { RetellAIServiceMapper } from "../RetellAIServiceMapper";
import type { RetellAIRepository, Language } from "../types";
import { getLlmId } from "../types";
type Dependencies = {
retellRepository: RetellAIRepository;
agentRepository: AgentRepositoryInterface;
phoneNumberRepository: PhoneNumberRepositoryInterface;
};
export class AgentService {
private logger = logger.getSubLogger({ prefix: ["AgentService"] });
constructor(
private retellRepository: RetellAIRepository,
private agentRepository: AgentRepositoryInterface
) {}
constructor(private deps: Dependencies) {}
private async createApiKey({ userId, teamId }: { userId: number; teamId?: number }) {
const apiKeyRepository = await PrismaApiKeyRepository.withGlobalPrisma();
@@ -45,7 +50,7 @@ export class AgentService {
}
try {
return await this.retellRepository.getAgent(agentId);
return await this.deps.retellRepository.getAgent(agentId);
} catch (error) {
this.logger.error("Failed to get agent from external AI service", {
agentId,
@@ -100,7 +105,7 @@ export class AgentService {
message: "Agent does not have an LLM configured.",
});
}
const llmDetails = await this.retellRepository.getLLM(llmId);
const llmDetails = await this.deps.retellRepository.getLLM(llmId);
if (!llmDetails) {
throw new HttpError({ statusCode: 404, message: "LLM details not found." });
@@ -160,7 +165,7 @@ export class AgentService {
const updatedGeneralTools = [...existing, ...newEventTools];
await this.retellRepository.updateLLM(llmId, { general_tools: updatedGeneralTools });
await this.deps.retellRepository.updateLLM(llmId, { general_tools: updatedGeneralTools });
} catch (error) {
if (error instanceof HttpError) {
throw error;
@@ -211,7 +216,7 @@ export class AgentService {
});
}
const llmDetails = await this.retellRepository.getLLM(llmId);
const llmDetails = await this.deps.retellRepository.getLLM(llmId);
if (!llmDetails) {
throw new HttpError({ statusCode: 404, message: "LLM details not found." });
@@ -227,7 +232,7 @@ export class AgentService {
const filteredTools = existing.filter((tool) => !toolNamesToRemove.includes(tool.name));
if (filteredTools.length !== existing.length) {
await this.retellRepository.updateLLM(llmId, { general_tools: filteredTools });
await this.deps.retellRepository.updateLLM(llmId, { general_tools: filteredTools });
this.logger.info("Removed event-specific tools from agent", {
agentId,
llmId,
@@ -281,7 +286,7 @@ export class AgentService {
});
}
const llmDetails = await this.retellRepository.getLLM(llmId);
const llmDetails = await this.deps.retellRepository.getLLM(llmId);
if (!llmDetails) {
throw new HttpError({ statusCode: 404, message: "LLM details not found." });
@@ -306,7 +311,7 @@ export class AgentService {
const toolNamesToRemove = toolsToRemove.map((tool) => tool.name);
const filteredTools = existing.filter((tool) => !toolNamesToRemove.includes(tool.name));
await this.retellRepository.updateLLM(llmId, { general_tools: filteredTools });
await this.deps.retellRepository.updateLLM(llmId, { general_tools: filteredTools });
this.logger.info("Cleaned up unused event-specific tools", {
agentId,
@@ -363,7 +368,7 @@ export class AgentService {
try {
const updateRequest = RetellAIServiceMapper.mapToUpdateAgentRequest(data);
return await this.retellRepository.updateAgent(agentId, updateRequest);
return await this.deps.retellRepository.updateAgent(agentId, updateRequest);
} catch (error) {
this.logger.error("Failed to update agent in external AI service", {
agentId,
@@ -386,7 +391,7 @@ export class AgentService {
teamId?: number;
scope?: "personal" | "team" | "all";
}) {
const agents = await this.agentRepository.findManyWithUserAccess({
const agents = await this.deps.agentRepository.findManyWithUserAccess({
userId,
teamId,
scope,
@@ -401,7 +406,7 @@ export class AgentService {
}
async getAgentWithDetails({ id, userId, teamId }: { id: string; userId: number; teamId?: number }) {
const agent = await this.agentRepository.findByIdWithUserAccessAndDetails({
const agent = await this.deps.agentRepository.findByIdWithUserAccessAndDetails({
id,
userId,
teamId,
@@ -425,7 +430,7 @@ export class AgentService {
});
}
const llmDetails = await this.retellRepository.getLLM(llmId);
const llmDetails = await this.deps.retellRepository.getLLM(llmId);
return RetellAIServiceMapper.formatAgentDetails(agent, retellAgent, llmDetails);
} catch (error) {
@@ -447,7 +452,7 @@ export class AgentService {
}
}
async createAgent({
async createOutboundAgent({
name: _name,
userId,
teamId,
@@ -463,7 +468,7 @@ export class AgentService {
const agentName = _name || `Agent - ${userId} ${uuidv4()}`;
if (teamId) {
const canManage = await this.agentRepository.canManageTeamResources({
const canManage = await this.deps.agentRepository.canManageTeamResources({
userId,
teamId,
});
@@ -477,7 +482,7 @@ export class AgentService {
const llmConfig = await setupAIConfiguration();
const agent = await this.agentRepository.create({
const agent = await this.deps.agentRepository.create({
name: agentName,
providerAgentId: llmConfig.agentId,
userId,
@@ -485,7 +490,7 @@ export class AgentService {
});
if (workflowStepId) {
await this.agentRepository.linkToWorkflowStep({
await this.deps.agentRepository.linkOutboundAgentToWorkflow({
workflowStepId,
agentId: agent.id,
});
@@ -498,6 +503,116 @@ export class AgentService {
};
}
async createInboundAgent({
name,
phoneNumber,
userId,
teamId,
workflowStepId,
aiConfigurationService,
}: {
name?: string;
phoneNumber: string;
userId: number;
teamId?: number;
workflowStepId: number;
aiConfigurationService: {
setupInboundAIConfiguration: () => Promise<{ llmId: string; agentId: string }>;
};
}) {
if (teamId) {
const canManage = await this.deps.agentRepository.canManageTeamResources({
userId,
teamId,
});
if (!canManage) {
throw new HttpError({
statusCode: 403,
message: "You don't have permission to create agents for this team.",
});
}
}
const isPhoneNumberValid = isValidPhoneNumber(phoneNumber);
if (!isPhoneNumberValid) {
throw new HttpError({
statusCode: 400,
message: "Invalid phone number",
});
}
let phoneNumberRecord;
if (teamId) {
phoneNumberRecord = await this.deps.phoneNumberRepository.findByPhoneNumberAndTeamId({
phoneNumber,
teamId,
userId,
});
} else {
phoneNumberRecord = await this.deps.phoneNumberRepository.findByPhoneNumberAndUserId({
phoneNumber,
userId,
});
}
if (!phoneNumberRecord) {
throw new HttpError({
statusCode: 404,
message: "Phone number not found or you don't have access to it",
});
}
if (phoneNumberRecord.inboundAgentId) {
throw new HttpError({
statusCode: 400,
message: "Inbound agent already configured for this phone number",
});
}
const agentName = name || `Inbound Agent - ${workflowStepId}`;
const llmConfig = await aiConfigurationService.setupInboundAIConfiguration();
const agent = await this.deps.agentRepository.create({
name: agentName,
providerAgentId: llmConfig.agentId,
userId,
teamId,
});
await this.deps.agentRepository.linkInboundAgentToWorkflow({
workflowStepId,
agentId: agent.id,
});
// Update the Retell phone number with the new inbound agent ID
await this.deps.retellRepository.updatePhoneNumber(phoneNumber, {
inbound_agent_id: llmConfig.agentId,
});
const updateInboundAgentIdResult = await this.deps.phoneNumberRepository.updateInboundAgentId({
id: phoneNumberRecord.id,
agentId: agent.id,
});
if (updateInboundAgentIdResult.count === 0) {
const conflictingAgentId = await this.deps.phoneNumberRepository.findInboundAgentIdByPhoneNumberId({
phoneNumberId: phoneNumberRecord.id,
});
throw new HttpError({
statusCode: 409,
message: `Inbound agent was configured by another request. Conflicting agent: ${conflictingAgentId}`,
});
}
return {
id: agent.id,
providerAgentId: agent.providerAgentId,
message: "Inbound agent created successfully",
};
}
async updateAgentConfiguration({
id,
userId,
@@ -524,7 +639,7 @@ export class AgentService {
data: AIPhoneServiceUpdateModelParams<AIPhoneServiceProviderType.RETELL_AI>
) => Promise<AIPhoneServiceModel<AIPhoneServiceProviderType.RETELL_AI>>;
}) {
const agent = await this.agentRepository.findByIdWithAdminAccess({
const agent = await this.deps.agentRepository.findByIdWithAdminAccess({
id,
userId,
teamId,
@@ -537,8 +652,13 @@ export class AgentService {
});
}
const updatedPrompt =
agent.inboundEventTypeId && generalPrompt
? replaceEventTypePlaceholders(generalPrompt, agent.inboundEventTypeId)
: generalPrompt;
const hasRetellUpdates =
generalPrompt !== undefined ||
updatedPrompt !== undefined ||
beginMessage !== undefined ||
generalTools !== undefined ||
voiceId !== undefined ||
@@ -551,10 +671,10 @@ export class AgentService {
if (
llmId &&
(generalPrompt !== undefined || beginMessage !== undefined || generalTools !== undefined)
(updatedPrompt !== undefined || beginMessage !== undefined || generalTools !== undefined)
) {
const llmUpdateData = RetellAIServiceMapper.extractLLMUpdateData(
generalPrompt,
updatedPrompt,
beginMessage,
generalTools
);
@@ -606,7 +726,7 @@ export class AgentService {
teamId?: number;
deleteAIConfiguration: (config: { agentId: string; llmId?: string }) => Promise<void>;
}) {
const agent = await this.agentRepository.findByIdWithAdminAccess({
const agent = await this.deps.agentRepository.findByIdWithAdminAccess({
id,
userId,
teamId,
@@ -637,7 +757,7 @@ export class AgentService {
});
}
await this.agentRepository.delete({ id });
await this.deps.agentRepository.delete({ id });
return { message: "Agent deleted successfully" };
}
@@ -20,10 +20,11 @@ const stripeErrorSchema = z.object({
export class BillingService {
private logger = logger.getSubLogger({ prefix: ["BillingService"] });
constructor(
private phoneNumberRepository: PhoneNumberRepositoryInterface,
private retellRepository: RetellAIRepository
private deps: {
phoneNumberRepository: PhoneNumberRepositoryInterface;
retellRepository: RetellAIRepository;
}
) {}
async generatePhoneNumberCheckoutSession({
@@ -112,12 +113,12 @@ export class BillingService {
}) {
// Find phone number with proper team authorization
const phoneNumber = teamId
? await this.phoneNumberRepository.findByIdWithTeamAccess({
? await this.deps.phoneNumberRepository.findByIdWithTeamAccess({
id: phoneNumberId,
teamId,
userId,
})
: await this.phoneNumberRepository.findByIdAndUserId({
: await this.deps.phoneNumberRepository.findByIdAndUserId({
id: phoneNumberId,
userId,
});
@@ -137,10 +138,10 @@ export class BillingService {
}
try {
await this.phoneNumberRepository.updateSubscriptionStatus({
await this.deps.phoneNumberRepository.updateSubscriptionStatus({
id: phoneNumberId,
subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED,
disconnectOutboundAgent: false,
disconnectAgents: false,
});
try {
@@ -154,7 +155,7 @@ export class BillingService {
stripeMessage: "Subscription resource not found",
});
} else {
await this.phoneNumberRepository.updateSubscriptionStatus({
await this.deps.phoneNumberRepository.updateSubscriptionStatus({
id: phoneNumberId,
subscriptionStatus: PhoneNumberSubscriptionStatus.ACTIVE,
});
@@ -163,15 +164,15 @@ export class BillingService {
}
// Disconnnect agent after cancelling from stripe
await this.phoneNumberRepository.updateSubscriptionStatus({
await this.deps.phoneNumberRepository.updateSubscriptionStatus({
id: phoneNumberId,
subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED,
disconnectOutboundAgent: true,
disconnectAgents: true,
});
// Delete the phone number from Retell, DB
try {
await this.retellRepository.deletePhoneNumber(phoneNumber.phoneNumber);
await this.deps.retellRepository.deletePhoneNumber(phoneNumber.phoneNumber);
} catch (error) {
this.logger.error("Failed to delete phone number from AI service, but subscription was cancelled:", {
error,
@@ -16,14 +16,16 @@ interface RetellAIServiceInterface {
): Promise<void>;
}
type Dependencies = {
retellRepository: RetellAIRepository;
agentRepository: AgentRepositoryInterface;
};
export class CallService {
private logger = logger.getSubLogger({ prefix: ["CallService"] });
private retellAIService?: RetellAIServiceInterface;
constructor(
private retellRepository: RetellAIRepository,
private agentRepository: AgentRepositoryInterface
) {}
constructor(private deps: Dependencies) {}
setRetellAIService(service: RetellAIServiceInterface): void {
this.retellAIService = service;
@@ -51,7 +53,7 @@ export class CallService {
const { fromNumber, toNumber, dynamicVariables } = data;
try {
return await this.retellRepository.createPhoneCall({
return await this.deps.retellRepository.createPhoneCall({
fromNumber,
toNumber,
dynamicVariables,
@@ -106,7 +108,7 @@ export class CallService {
});
}
const agent = await this.agentRepository.findByIdWithCallAccess({
const agent = await this.deps.agentRepository.findByIdWithCallAccess({
id: agentId,
userId,
});
@@ -193,7 +195,7 @@ export class CallService {
identifier: `web-call:${userId}`,
});
const agent = await this.agentRepository.findByIdWithCallAccess({
const agent = await this.deps.agentRepository.findByIdWithCallAccess({
id: agentId,
userId,
});
@@ -233,7 +235,7 @@ export class CallService {
};
try {
const webCall = await this.retellRepository.createWebCall({
const webCall = await this.deps.retellRepository.createWebCall({
agentId: agent.providerAgentId,
dynamicVariables,
});
@@ -306,7 +308,7 @@ export class CallService {
return [];
}
const callsResponse = await this.retellRepository.listCalls({
const callsResponse = await this.deps.retellRepository.listCalls({
filter_criteria: {
from_number: filters.fromNumber,
...(filters?.toNumber && { to_number: filters.toNumber }),
@@ -14,15 +14,16 @@ import type { TransactionInterface } from "../../interfaces/TransactionInterface
import { RetellAIServiceMapper } from "../RetellAIServiceMapper";
import type { RetellAIRepository } from "../types";
type Dependencies = {
retellRepository: RetellAIRepository;
agentRepository: AgentRepositoryInterface;
phoneNumberRepository: PhoneNumberRepositoryInterface;
transactionManager: TransactionInterface;
};
export class PhoneNumberService {
private logger = logger.getSubLogger({ prefix: ["PhoneNumberService"] });
constructor(
private retellRepository: RetellAIRepository,
private agentRepository: AgentRepositoryInterface,
private phoneNumberRepository: PhoneNumberRepositoryInterface,
private transactionManager: TransactionInterface
) {}
constructor(private deps: Dependencies) {}
async importPhoneNumber(
data: AIPhoneServiceImportPhoneNumberParamsExtended
@@ -46,8 +47,8 @@ export class PhoneNumberService {
};
try {
return await this.transactionManager.executeInTransaction(async (txContext) => {
transactionState.retellPhoneNumber = await this.retellRepository.importPhoneNumber({
return await this.deps.transactionManager.executeInTransaction(async (txContext) => {
transactionState.retellPhoneNumber = await this.deps.retellRepository.importPhoneNumber({
phone_number: rest.phone_number,
termination_uri: rest.termination_uri,
sip_trunk_auth_username: rest.sip_trunk_auth_username,
@@ -65,9 +66,12 @@ export class PhoneNumberService {
transactionState.databaseRecordCreated = true;
if (agent) {
await this.retellRepository.updatePhoneNumber(transactionState.retellPhoneNumber.phone_number, {
outbound_agent_id: agent.providerAgentId,
});
await this.deps.retellRepository.updatePhoneNumber(
transactionState.retellPhoneNumber.phone_number,
{
outbound_agent_id: agent.providerAgentId,
}
);
transactionState.agentAssigned = true;
}
@@ -83,7 +87,7 @@ export class PhoneNumberService {
data: AIPhoneServiceCreatePhoneNumberParams
): Promise<AIPhoneServicePhoneNumber<AIPhoneServiceProviderType.RETELL_AI> & { provider: string }> {
try {
const phoneNumber = await this.retellRepository.createPhoneNumber(data);
const phoneNumber = await this.deps.retellRepository.createPhoneNumber(data);
return {
...phoneNumber,
provider: AIPhoneServiceProviderType.RETELL_AI,
@@ -119,12 +123,12 @@ export class PhoneNumberService {
}
const phoneNumberToDelete = teamId
? await this.phoneNumberRepository.findByPhoneNumberAndTeamId({
? await this.deps.phoneNumberRepository.findByPhoneNumberAndTeamId({
phoneNumber,
teamId,
userId,
})
: await this.phoneNumberRepository.findByPhoneNumberAndUserId({
: await this.deps.phoneNumberRepository.findByPhoneNumberAndUserId({
phoneNumber,
userId,
});
@@ -150,7 +154,7 @@ export class PhoneNumberService {
}
try {
await this.retellRepository.updatePhoneNumber(phoneNumber, {
await this.deps.retellRepository.updatePhoneNumber(phoneNumber, {
inbound_agent_id: null,
outbound_agent_id: null,
});
@@ -161,10 +165,10 @@ export class PhoneNumberService {
});
}
await this.retellRepository.deletePhoneNumber(phoneNumber);
await this.deps.retellRepository.deletePhoneNumber(phoneNumber);
if (deleteFromDB) {
await this.phoneNumberRepository.deletePhoneNumber({ phoneNumber });
await this.deps.phoneNumberRepository.deletePhoneNumber({ phoneNumber });
}
}
@@ -179,7 +183,7 @@ export class PhoneNumberService {
}
try {
return await this.retellRepository.getPhoneNumber(phoneNumber);
return await this.deps.retellRepository.getPhoneNumber(phoneNumber);
} catch (error) {
this.logger.error("Failed to get phone number from external AI service", {
phoneNumber,
@@ -211,7 +215,7 @@ export class PhoneNumberService {
}
try {
return await this.retellRepository.updatePhoneNumber(phoneNumber, data);
return await this.deps.retellRepository.updatePhoneNumber(phoneNumber, data);
} catch (error) {
this.logger.error("Failed to update phone number in external AI service", {
phoneNumber,
@@ -246,12 +250,12 @@ export class PhoneNumberService {
}
const phoneNumberRecord = teamId
? await this.phoneNumberRepository.findByPhoneNumberAndTeamId({
? await this.deps.phoneNumberRepository.findByPhoneNumberAndTeamId({
phoneNumber,
teamId,
userId,
})
: await this.phoneNumberRepository.findByPhoneNumberAndUserId({
: await this.deps.phoneNumberRepository.findByPhoneNumberAndUserId({
phoneNumber,
userId,
});
@@ -285,7 +289,7 @@ export class PhoneNumberService {
});
}
await this.phoneNumberRepository.updateAgents({
await this.deps.phoneNumberRepository.updateAgents({
id: phoneNumberRecord.id,
inboundProviderAgentId: inboundAgentId,
outboundProviderAgentId: outboundAgentId,
@@ -296,7 +300,7 @@ export class PhoneNumberService {
private async validateTeamPermissions(userId: number, teamId?: number) {
if (teamId) {
const canManage = await this.agentRepository.canManageTeamResources({
const canManage = await this.deps.agentRepository.canManageTeamResources({
userId,
teamId,
});
@@ -319,7 +323,7 @@ export class PhoneNumberService {
let agent = null;
if (agentId) {
agent = await this.agentRepository.findByIdWithUserAccess({
agent = await this.deps.agentRepository.findByIdWithUserAccess({
agentId,
userId,
});
@@ -341,7 +345,7 @@ export class PhoneNumberService {
type: "inbound" | "outbound"
) {
if (agentId) {
const agent = await this.agentRepository.findByProviderAgentIdWithUserAccess({
const agent = await this.deps.agentRepository.findByProviderAgentIdWithUserAccess({
providerAgentId: agentId,
userId,
});
@@ -376,7 +380,7 @@ export class PhoneNumberService {
this.logger.warn("Attempting cleanup of Retell phone number due to transaction failure", {
phoneNumber: transactionState.retellPhoneNumber.phone_number,
});
await this.retellRepository.deletePhoneNumber(transactionState.retellPhoneNumber.phone_number);
await this.deps.retellRepository.deletePhoneNumber(transactionState.retellPhoneNumber.phone_number);
this.logger.info("Successfully cleaned up Retell phone number", {
phoneNumber: transactionState.retellPhoneNumber.phone_number,
});
@@ -22,7 +22,7 @@ This directory contains the refactored RetellAI services that follow the Single
**Responsibility**: Agent CRUD operations
- `createAgent()` - Create new agents
- `createOutboundAgent()` - Create new agents
- `updateAgent()` - Update agent properties
- `deleteAgent()` - Remove agents
- `listAgents()` - Get agent lists
@@ -83,7 +83,7 @@ const { llmId, agentId } = await aiConfigService.setupAIConfiguration({
});
// Create agent
const agent = await agentService.createAgent({
const agent = await agentService.createOutboundAgent({
name: 'Support Agent',
userId: 1,
userTimeZone: 'America/New_York',
@@ -3,19 +3,22 @@ import logger from "@calcom/lib/logger";
import type { RetellAIRepository, RetellVoice } from "../types";
type Dependencies = {
retellRepository: RetellAIRepository;
};
export class VoiceService {
private logger = logger.getSubLogger({ prefix: ["VoiceService"] });
constructor(private retellRepository: RetellAIRepository) {}
constructor(private deps: Dependencies) {}
async listVoices(): Promise<RetellVoice[]> {
try {
const voices = await this.retellRepository.listVoices();
const voices = await this.deps.retellRepository.listVoices();
this.logger.info("Retrieved voices successfully", {
count: voices.length,
});
return voices;
} catch (error) {
this.logger.error("Failed to list voices from external AI service", {
@@ -27,4 +30,4 @@ export class VoiceService {
});
}
}
}
}
@@ -11,7 +11,9 @@ describe("AIConfigurationService", () => {
vi.clearAllMocks();
mocks = setupBasicMocks();
service = new AIConfigurationService(mocks.mockRetellRepository);
service = new AIConfigurationService({
retellRepository: mocks.mockRetellRepository,
});
});
afterEach(() => {
@@ -30,7 +32,7 @@ describe("AIConfigurationService", () => {
const mockAgent = createMockAgent();
mocks.mockRetellRepository.createLLM.mockResolvedValue(mockLLM);
mocks.mockRetellRepository.createAgent.mockResolvedValue(mockAgent);
mocks.mockRetellRepository.createOutboundAgent.mockResolvedValue(mockAgent);
const result = await service.setupAIConfiguration(validConfig);
@@ -40,7 +42,7 @@ describe("AIConfigurationService", () => {
});
expect(mocks.mockRetellRepository.createLLM).toHaveBeenCalled();
expect(mocks.mockRetellRepository.createAgent).toHaveBeenCalled();
expect(mocks.mockRetellRepository.createOutboundAgent).toHaveBeenCalled();
});
it("should handle LLM creation failure", async () => {
@@ -48,7 +48,10 @@ describe("BillingService", () => {
});
stripe.subscriptions.cancel.mockResolvedValue({});
service = new BillingService(mocks.mockPhoneNumberRepository, mocks.mockRetellRepository);
service = new BillingService({
phoneNumberRepository: mocks.mockPhoneNumberRepository,
retellRepository: mocks.mockRetellRepository,
});
});
afterEach(() => {
@@ -166,12 +169,12 @@ describe("BillingService", () => {
expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenNthCalledWith(1, {
id: 1,
subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED,
disconnectOutboundAgent: false,
disconnectAgents: false,
});
expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenNthCalledWith(2, {
id: 1,
subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED,
disconnectOutboundAgent: true,
disconnectAgents: true,
});
expect(mocks.mockRetellRepository.deletePhoneNumber).toHaveBeenCalledWith("+1234567890");
});
@@ -294,17 +297,17 @@ describe("BillingService", () => {
// Should attempt to cancel
expect(stripe.subscriptions.cancel).toHaveBeenCalledWith("sub_123");
// Should update database twice: first CANCELLED (disconnectOutboundAgent: false), then final CANCELLED (disconnectOutboundAgent: true)
// Should update database twice: first CANCELLED (disconnectAgents: false), then final CANCELLED (disconnectAgents: true)
expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenCalledTimes(2);
expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenNthCalledWith(1, {
id: 1,
subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED,
disconnectOutboundAgent: false,
disconnectAgents: false,
});
expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenNthCalledWith(2, {
id: 1,
subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED,
disconnectOutboundAgent: true,
disconnectAgents: true,
});
});
@@ -330,7 +333,7 @@ describe("BillingService", () => {
expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenNthCalledWith(1, {
id: 1,
subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED,
disconnectOutboundAgent: false,
disconnectAgents: false,
});
expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenNthCalledWith(2, {
id: 1,
@@ -36,7 +36,10 @@ describe("CallService", () => {
updateToolsFromAgentId: vi.fn().mockResolvedValue(undefined),
};
service = new CallService(mocks.mockRetellRepository, mocks.mockAgentRepository);
service = new CallService({
retellRepository: mocks.mockRetellRepository,
agentRepository: mocks.mockAgentRepository,
});
service.setRetellAIService(mockRetellAIService);
});
@@ -20,12 +20,12 @@ describe("PhoneNumberService", () => {
vi.clearAllMocks();
mocks = setupBasicMocks();
service = new PhoneNumberService(
mocks.mockRetellRepository,
mocks.mockAgentRepository,
mocks.mockPhoneNumberRepository,
mocks.mockTransactionManager
);
service = new PhoneNumberService({
retellRepository: mocks.mockRetellRepository,
agentRepository: mocks.mockAgentRepository,
phoneNumberRepository: mocks.mockPhoneNumberRepository,
transactionManager: mocks.mockTransactionManager,
});
});
afterEach(() => {
@@ -91,7 +91,7 @@ export const createMockRetellRepository = (): RetellAIRepository & {
getLLM: vi.fn(),
updateLLM: vi.fn(),
deleteLLM: vi.fn(),
createAgent: vi.fn(),
createOutboundAgent: vi.fn(),
getAgent: vi.fn(),
updateAgent: vi.fn(),
deleteAgent: vi.fn(),
@@ -117,7 +117,7 @@ export const createMockAgentRepository = (): AgentRepositoryInterface & {
findByIdWithAdminAccess: vi.fn(),
findByIdWithCallAccess: vi.fn(),
delete: vi.fn(),
linkToWorkflowStep: vi.fn(),
linkOutboundAgentToWorkflow: vi.fn(),
};
};
@@ -103,7 +103,7 @@ export type CreateAgentRequest = Retell.AgentCreateParams;
export type UpdateLLMRequest = Retell.LlmUpdateParams;
export type UpdateAgentRequest = Retell.AgentUpdateParams;
export type Agent = NonNullable<
Awaited<ReturnType<typeof PrismaAgentRepository.findByIdWithUserAccessAndDetails>>
Awaited<ReturnType<PrismaAgentRepository["findByIdWithUserAccessAndDetails"]>>
>;
export type RetellAgentWithDetails = {
@@ -113,6 +113,7 @@ export type RetellAgentWithDetails = {
enabled: boolean;
userId: number | null;
teamId: number | null;
inboundEventTypeId?: number | null;
outboundPhoneNumbers: Array<{
id: number;
phoneNumber: string;
@@ -167,7 +168,7 @@ export interface RetellAIRepository {
deleteLLM(llmId: string): Promise<void>;
// Agent operations
createAgent(data: CreateAgentRequest): Promise<RetellAgent>;
createOutboundAgent(data: CreateAgentRequest): Promise<RetellAgent>;
getAgent(agentId: string): Promise<RetellAgent>;
updateAgent(agentId: string, data: UpdateAgentRequest): Promise<RetellAgent>;
deleteAgent(agentId: string): Promise<void>;
@@ -181,7 +182,7 @@ export interface RetellAIRepository {
// Call operations
createPhoneCall(data: CreatePhoneCallParams): Promise<RetellCall>;
listCalls(params: RetellCallListParams): Promise<RetellCallListResponse>;
createWebCall(
@@ -3,7 +3,7 @@ import stripe from "@calcom/features/ee/payments/server/stripe";
import { PrismaAgentRepository } from "@calcom/lib/server/repository/PrismaAgentRepository";
import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository";
import { CreditsRepository } from "@calcom/lib/server/repository/credits";
import { prisma } from "@calcom/prisma";
import prisma from "@calcom/prisma";
import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums";
import { CHECKOUT_SESSION_TYPES } from "../../constants";
@@ -100,7 +100,8 @@ async function handleCalAIPhoneNumberSubscription(
throw new HttpCode(400, "Missing agentId for phone number subscription");
}
const agent = await PrismaAgentRepository.findByIdWithUserAccess({
const agentRepo = new PrismaAgentRepository(prisma);
const agent = await agentRepo.findByIdWithUserAccess({
agentId,
userId,
teamId: teamId ?? undefined,
@@ -130,7 +131,8 @@ async function handleCalAIPhoneNumberSubscription(
throw new HttpCode(400, "Invalid subscription data");
}
const newNumber = await PrismaPhoneNumberRepository.createPhoneNumber({
const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
const newNumber = await phoneNumberRepo.createPhoneNumber({
userId,
teamId: teamId ?? undefined,
phoneNumber: calAIPhoneNumber.phone_number,
@@ -144,7 +146,7 @@ async function handleCalAIPhoneNumberSubscription(
try {
console.log("Attempting to link agent to phone number:", { agentId, phoneNumberId: newNumber.id });
const agent = await PrismaAgentRepository.findByIdWithUserAccess({
const agent = await agentRepo.findByIdWithUserAccess({
agentId,
userId,
});
@@ -1,5 +1,6 @@
import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone";
import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository";
import prisma from "@calcom/prisma";
import type { LazyModule, SWHMap } from "./__handler";
import { HttpCode } from "./__handler";
@@ -12,8 +13,9 @@ const STRIPE_TEAM_PRODUCT_ID = process.env.STRIPE_TEAM_PRODUCT_ID || "";
const stripeWebhookProductHandler = (handlers: Handlers) => async (data: Data) => {
const subscription = data.object;
const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
const phoneNumber = await PrismaPhoneNumberRepository.findByStripeSubscriptionId({
const phoneNumber = await phoneNumberRepo.findByStripeSubscriptionId({
stripeSubscriptionId: subscription.id,
});
@@ -62,7 +64,7 @@ const stripeWebhookProductHandler = (handlers: Handlers) => async (data: Data) =
async function handleCalAIPhoneNumberSubscriptionDeleted(
subscription: Data["object"],
phoneNumber: NonNullable<Awaited<ReturnType<typeof PrismaPhoneNumberRepository.findByStripeSubscriptionId>>>
phoneNumber: NonNullable<Awaited<ReturnType<PrismaPhoneNumberRepository["findByStripeSubscriptionId"]>>>
) {
if (!subscription.id) {
throw new HttpCode(400, "Subscription ID not found");
@@ -1,5 +1,5 @@
import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository";
import { prisma } from "@calcom/prisma";
import prisma from "@calcom/prisma";
import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums";
import type { SWHMap } from "./__handler";
@@ -14,7 +14,8 @@ const handler = async (data: Data) => {
throw new HttpCode(400, "Subscription ID not found");
}
const phoneNumber = await PrismaPhoneNumberRepository.findByStripeSubscriptionId({
const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
const phoneNumber = await phoneNumberRepo.findByStripeSubscriptionId({
stripeSubscriptionId: subscription.id,
});
File diff suppressed because it is too large Load Diff
@@ -1,13 +1,7 @@
import { getCoreRowModel, getSortedRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table";
import { useMemo, useState } from "react";
import {
DataTableProvider,
DataTableWrapper,
DataTableToolbar,
DataTableFilters,
useDataTable,
} from "@calcom/features/data-table";
import { DataTableProvider, DataTableWrapper } from "@calcom/features/data-table";
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
@@ -21,8 +15,8 @@ import { useVoicePreview } from "../hooks/useVoicePreview";
type Voice = {
voice_id: string;
voice_name: string;
provider: 'elevenlabs' | 'openai' | 'deepgram';
gender: 'male' | 'female';
provider: "elevenlabs" | "openai" | "deepgram";
gender: "male" | "female";
accent?: string;
age?: string;
preview_audio_url?: string;
@@ -35,7 +29,13 @@ type VoiceSelectionDialogProps = {
onVoiceSelect: (voiceId: string) => void;
};
function VoiceSelectionTable({ selectedVoiceId, onVoiceSelect }: { selectedVoiceId?: string; onVoiceSelect: (voiceId: string) => void }) {
function VoiceSelectionTable({
selectedVoiceId,
onVoiceSelect,
}: {
selectedVoiceId?: string;
onVoiceSelect: (voiceId: string) => void;
}) {
return (
<DataTableProvider useSegments={useSegments} defaultPageSize={1000}>
<VoiceSelectionContent selectedVoiceId={selectedVoiceId} onVoiceSelect={onVoiceSelect} />
@@ -43,14 +43,19 @@ function VoiceSelectionTable({ selectedVoiceId, onVoiceSelect }: { selectedVoice
);
}
function VoiceSelectionContent({ selectedVoiceId, onVoiceSelect }: { selectedVoiceId?: string; onVoiceSelect: (voiceId: string) => void }) {
function VoiceSelectionContent({
selectedVoiceId,
onVoiceSelect,
}: {
selectedVoiceId?: string;
onVoiceSelect: (voiceId: string) => void;
}) {
const { t } = useLocale();
const { playingVoiceId, handlePlayVoice } = useVoicePreview();
const [rowSelection, setRowSelection] = useState({});
const { data: voices, isLoading } = trpc.viewer.aiVoiceAgent.listVoices.useQuery();
const handleUseVoice = (voiceId: string) => {
onVoiceSelect(voiceId);
showToast("Voice selected successfully", "success");
@@ -78,16 +83,16 @@ function VoiceSelectionContent({ selectedVoiceId, onVoiceSelect }: { selectedVoi
onClick={() => handlePlayVoice(row.original.preview_audio_url, row.original.voice_id)}
className="rounded-full">
{playingVoiceId === row.original.voice_id ? (
<Icon name="pause" className="h-3 w-3 text-default" />
<Icon name="pause" className="text-default h-3 w-3" />
) : (
<Icon name="play" className="h-3 w-3 text-default" />
<Icon name="play" className="text-default h-3 w-3" />
)}
</Button>
<div className="flex items-center gap-2">
<div className="w-10 h-10 bg-subtle rounded-full flex items-center justify-center">
<Icon name="user" className="h-5 w-5 text-default" />
<div className="bg-subtle flex h-10 w-10 items-center justify-center rounded-full">
<Icon name="user" className="text-default h-5 w-5" />
</div>
<span className="font-medium text-emphasis">{row.original.voice_name}</span>
<span className="text-emphasis font-medium">{row.original.voice_name}</span>
</div>
</div>
),
@@ -100,24 +105,22 @@ function VoiceSelectionContent({ selectedVoiceId, onVoiceSelect }: { selectedVoi
cell: ({ row }) => (
<div className="flex gap-2 text-sm">
{row.original.accent && (
<span className="px-2 py-1 bg-subtle text-default rounded-md text-xs">
<span className="bg-subtle text-default rounded-md px-2 py-1 text-xs">
{row.original.accent}
</span>
)}
{row.original.gender && (
<span className="px-2 py-1 bg-subtle text-default rounded-md text-xs">
<span className="bg-subtle text-default rounded-md px-2 py-1 text-xs">
{row.original.gender}
</span>
)}
{row.original.provider && (
<span className="px-2 py-1 bg-subtle text-default rounded-md text-xs">
<span className="bg-subtle text-default rounded-md px-2 py-1 text-xs">
{row.original.provider}
</span>
)}
{row.original.age && (
<span className="px-2 py-1 bg-subtle text-default rounded-md text-xs">
{row.original.age}
</span>
<span className="bg-subtle text-default rounded-md px-2 py-1 text-xs">{row.original.age}</span>
)}
</div>
),
@@ -127,9 +130,7 @@ function VoiceSelectionContent({ selectedVoiceId, onVoiceSelect }: { selectedVoi
accessorKey: "voice_id",
header: t("voice_id"),
size: 200,
cell: ({ row }) => (
<span className="text-sm text-muted font-mono">{row.original.voice_id}</span>
),
cell: ({ row }) => <span className="text-muted font-mono text-sm">{row.original.voice_id}</span>,
},
{
id: "actions",
@@ -142,13 +143,7 @@ function VoiceSelectionContent({ selectedVoiceId, onVoiceSelect }: { selectedVoi
color={selectedVoiceId === row.original.voice_id ? "primary" : "secondary"}
onClick={() => handleUseVoice(row.original.voice_id)}
className="whitespace-nowrap">
{selectedVoiceId === row.original.voice_id ? (
<>
{t("current_voice")}
</>
) : (
t("use_voice")
)}
{selectedVoiceId === row.original.voice_id ? <>{t("current_voice")}</> : t("use_voice")}
</Button>
),
},
@@ -171,7 +166,7 @@ function VoiceSelectionContent({ selectedVoiceId, onVoiceSelect }: { selectedVoi
});
return (
<div className="max-h-[500px] overflow-auto border border-subtle rounded-lg">
<div className="border-subtle max-h-[500px] overflow-auto rounded-lg border">
<DataTableWrapper<Voice>
testId="voice-selection-data-table"
table={table}
@@ -199,17 +194,11 @@ export function VoiceSelectionDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent enableOverflow className="sm:max-w-7xl h-[80vh] flex flex-col p-6">
<DialogHeader
title={t("select_voice")}
subtitle={t("choose_a_voice_for_your_agent")}
/>
<DialogContent enableOverflow className="flex h-[80vh] flex-col p-6 sm:max-w-7xl">
<DialogHeader title={t("select_voice")} subtitle={t("choose_a_voice_for_your_agent")} />
<div className="flex-1 overflow-hidden min-h-0 mt-4">
<VoiceSelectionTable
selectedVoiceId={selectedVoiceId}
onVoiceSelect={handleVoiceSelect}
/>
<div className="mt-4 min-h-0 flex-1 overflow-hidden">
<VoiceSelectionTable selectedVoiceId={selectedVoiceId} onVoiceSelect={handleVoiceSelect} />
</div>
</DialogContent>
</Dialog>
@@ -454,8 +454,7 @@ export function WebCallDialog({
type="button"
onClick={handleStartCall}
loading={createWebCallMutation.isPending || callStatus === "connecting"}
disabled={!hasCredits || createWebCallMutation.isPending || callStatus === "connecting"}
>
disabled={!hasCredits || createWebCallMutation.isPending || callStatus === "connecting"}>
<Icon name="phone" className="mr-2 h-4 w-4" />
{callStatus === "connecting" ? t("connecting") : t("start_web_call")}
</Button>
@@ -9,12 +9,12 @@ import type { WorkflowPermissions } from "@calcom/lib/server/repository/workflow
import type { WorkflowActions } from "@calcom/prisma/enums";
import { WorkflowTemplates } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { FormCard, FormCardBody } from "@calcom/ui/components/card";
import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { useAgentsData } from "../hooks/useAgentsData";
import { isCalAIAction, isSMSAction } from "../lib/actionHelperFunctions";
import type { FormValues } from "../pages/workflow";
import { AddActionDialog } from "./AddActionDialog";
@@ -58,8 +58,6 @@ export default function WorkflowDetailsPage(props: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [eventTypeId]);
const steps = form.getValues("steps") || [];
const addAction = (
action: WorkflowActions,
sendTo?: string,
@@ -96,19 +94,14 @@ export default function WorkflowDetailsPage(props: Props) {
includeCalendarEvent: false,
verifiedAt: SCANNING_WORKFLOW_STEPS ? null : new Date(),
agentId: null,
inboundAgentId: null,
};
steps?.push(step);
form.setValue("steps", steps);
};
const agentQueriesTrpc = trpc.useQueries((t) =>
steps.map((step, index) => {
const watchedAgentId = form.watch(`steps.${index}.agentId`);
const agentId = step?.agentId ?? watchedAgentId ?? null;
return t.viewer.aiVoiceAgent.get({ id: agentId ?? "" }, { enabled: !!agentId });
})
);
const { outboundAgentQueries: agentQueriesTrpc, inboundAgentQueries: inboundAgentQueriesTrpc } =
useAgentsData(form);
return (
<>
@@ -145,6 +138,8 @@ export default function WorkflowDetailsPage(props: Props) {
{form.getValues("steps")?.map((step, index) => {
const agentData = agentQueriesTrpc[index]?.data;
const isAgentLoading = agentQueriesTrpc[index]?.isPending;
const inboundAgentData = inboundAgentQueriesTrpc[index]?.data;
const isInboundAgentLoading = inboundAgentQueriesTrpc[index]?.isPending;
return (
<div key={index}>
@@ -207,6 +202,9 @@ export default function WorkflowDetailsPage(props: Props) {
isDeleteStepDialogOpen={isDeleteStepDialogOpen}
isAgentLoading={isAgentLoading}
agentData={agentData}
inboundAgentData={inboundAgentData}
isInboundAgentLoading={isInboundAgentLoading}
allOptions={allOptions}
/>
</FormCardBody>
</FormCard>
@@ -67,10 +67,10 @@ import emailRatingTemplate from "../lib/reminders/templates/emailRatingTemplate"
import emailReminderTemplate from "../lib/reminders/templates/emailReminderTemplate";
import type { FormValues } from "../pages/workflow";
import "../style/styles.css";
import { AgentConfigurationSheet } from "./AgentConfigurationSheet";
import { TestPhoneCallDialog } from "./TestPhoneCallDialog";
import { TimeTimeUnitInput } from "./TimeTimeUnitInput";
import { WebCallDialog } from "./WebCallDialog";
import { AgentConfigurationSheet } from "./agent-configuration/AgentConfigurationSheet";
type User = RouterOutputs["viewer"]["me"]["get"];
@@ -92,6 +92,8 @@ type WorkflowStepProps = {
isDeleteStepDialogOpen?: boolean;
agentData?: RetellAgentWithDetails;
isAgentLoading?: boolean;
inboundAgentData?: RetellAgentWithDetails;
isInboundAgentLoading?: boolean;
};
const getTimeSectionText = (trigger: WorkflowTriggerEvents, t: TFunction) => {
@@ -157,6 +159,8 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
allOptions,
agentData,
isAgentLoading,
inboundAgentData,
isInboundAgentLoading: _isInboundAgentLoading,
isDeleteStepDialogOpen,
setIsDeleteStepDialogOpen,
onSaveWorkflow,
@@ -171,10 +175,10 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
const { data: userTeams } = trpc.viewer.teams.list.useQuery({}, { enabled: !teamId });
const [agentConfigurationSheet, setAgentConfigurationSheet] = useState<{
open: boolean;
activeTab?: "prompt" | "phoneNumber";
activeTab?: "outgoingCalls" | "phoneNumber" | "incomingCalls";
}>({
open: false,
activeTab: "prompt",
activeTab: "outgoingCalls",
});
const creditsTeamId = userTeams?.find(
@@ -210,12 +214,15 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
});
const stepAgentId = step?.agentId || form.watch(`steps.${step ? step.stepNumber - 1 : 0}.agentId`) || null;
const stepInboundAgentId =
step?.inboundAgentId || form.watch(`steps.${step ? step.stepNumber - 1 : 0}.inboundAgentId`) || null;
const updateAgentMutation = trpc.viewer.aiVoiceAgent.update.useMutation({
onSuccess: async () => {
showToast(t("agent_updated_successfully"), "success");
if (stepAgentId) {
utils.viewer.aiVoiceAgent.get.invalidate({ id: stepAgentId });
const currentAgentId = stepAgentId || stepInboundAgentId;
if (currentAgentId) {
utils.viewer.aiVoiceAgent.get.invalidate({ id: currentAgentId });
}
},
onError: (error: { message: string }) => {
@@ -227,8 +234,9 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
onSuccess: async () => {
showToast(t("phone_number_unsubscribed_successfully"), "success");
setIsUnsubscribeDialogOpen(false);
if (stepAgentId) {
utils.viewer.aiVoiceAgent.get.invalidate({ id: stepAgentId });
const currentAgentId = stepAgentId || stepInboundAgentId;
if (currentAgentId) {
utils.viewer.aiVoiceAgent.get.invalidate({ id: currentAgentId });
}
},
onError: (error: { message: string }) => {
@@ -844,7 +852,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
return;
}
if (!props.readOnly) {
setAgentConfigurationSheet({ open: true, activeTab: "prompt" });
setAgentConfigurationSheet({ open: true, activeTab: "outgoingCalls" });
}
}}>
<div>
@@ -962,7 +970,9 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
<DropdownItem
type="button"
StartIcon="pencil"
onClick={() => setAgentConfigurationSheet({ open: true, activeTab: "prompt" })}>
onClick={() =>
setAgentConfigurationSheet({ open: true, activeTab: "outgoingCalls" })
}>
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
@@ -1519,11 +1529,12 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
activeTab={agentConfigurationSheet.activeTab}
onOpenChange={(val) => setAgentConfigurationSheet((prev) => ({ ...prev, open: val }))}
agentId={stepAgentId}
inboundAgentId={stepInboundAgentId}
agentData={agentData}
inboundAgentData={inboundAgentData}
onUpdate={(data) => {
updateAgentMutation.mutate({
//eslint-disable-next-line @typescript-eslint/no-non-null-assertion
id: stepAgentId!,
id: data.id,
teamId: teamId,
generalPrompt: data.generalPrompt,
beginMessage: data.beginMessage,
@@ -1536,6 +1547,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
workflowId={params?.workflow as string}
workflowStepId={step?.id}
form={form}
eventTypeOptions={props.allOptions}
/>
)}
@@ -1543,7 +1555,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
<TestPhoneCallDialog
open={isTestAgentDialogOpen}
onOpenChange={setIsTestAgentDialogOpen}
agentId={stepAgentId}
agentId={stepAgentId || ""}
teamId={teamId}
form={form}
/>
@@ -1553,7 +1565,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
<WebCallDialog
open={isWebCallDialogOpen}
onOpenChange={setIsWebCallDialogOpen}
agentId={stepAgentId}
agentId={stepAgentId || ""}
teamId={teamId}
isOrganization={props.isOrganization}
form={form}
@@ -0,0 +1,374 @@
import { useState } from "react";
import type { UseFormReturn } from "react-hook-form";
import type { Language } from "@calcom/features/calAIPhone/providers/retellAI/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import {
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@calcom/ui/components/dropdown";
import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui/components/form";
import { ToggleGroup } from "@calcom/ui/components/form";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetBody,
SheetFooter,
} from "@calcom/ui/components/sheet";
import { showToast } from "@calcom/ui/components/toast";
import type { FormValues } from "../../pages/workflow";
import { WebCallDialog } from "../WebCallDialog";
import { IncomingCallsTab } from "./components/tabs/IncomingCallsTab";
import { OutgoingCallsTab } from "./components/tabs/OutgoingCallsTab";
import { PhoneNumberTab } from "./components/tabs/PhoneNumberTab";
import { useAgentForms } from "./hooks/useAgentForms";
import type { AgentFormValues } from "./types/schemas";
import { restorePromptComplexity } from "./utils/promptUtils";
type AgentConfigurationSheetProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
agentId?: string | null;
inboundAgentId?: string | null;
agentData?: RouterOutputs["viewer"]["aiVoiceAgent"]["get"];
inboundAgentData?: RouterOutputs["viewer"]["aiVoiceAgent"]["get"];
onUpdate: (data: AgentFormValues & { id: string }) => void;
readOnly?: boolean;
teamId?: number;
isOrganization?: boolean;
workflowId?: string;
workflowStepId?: number;
activeTab?: "outgoingCalls" | "phoneNumber" | "incomingCalls";
form: UseFormReturn<FormValues>;
eventTypeOptions?: Option[];
};
export function AgentConfigurationSheet({
open,
activeTab: _activeTab,
onOpenChange,
agentId,
inboundAgentId,
agentData,
inboundAgentData,
readOnly = false,
teamId,
isOrganization = false,
workflowId,
workflowStepId,
form,
eventTypeOptions = [],
}: AgentConfigurationSheetProps) {
const { t } = useLocale();
const utils = trpc.useUtils();
const [activeTab, setActiveTab] = useState<"outgoingCalls" | "phoneNumber" | "incomingCalls">(
_activeTab ?? "outgoingCalls"
);
const [isWebCallDialogOpen, setIsWebCallDialogOpen] = useState(false);
const activeAgentId = activeTab === "incomingCalls" ? inboundAgentId : agentId;
const { outboundAgentForm, inboundAgentForm } = useAgentForms({ agentData, inboundAgentData });
const updateAgentMutation = trpc.viewer.aiVoiceAgent.update.useMutation({
onSuccess: async () => {
if (activeAgentId) {
await utils.viewer.aiVoiceAgent.get.invalidate({ id: activeAgentId });
}
},
onError: (error: { message: string }) => {
showToast(error.message, "error");
},
});
const handleAgentUpdate = async (data: AgentFormValues) => {
if (!agentId) return;
const updatePayload = {
generalPrompt: restorePromptComplexity(data.generalPrompt),
beginMessage: data.beginMessage,
language: data.language as Language,
voiceId: data.voiceId,
};
await updateAgentMutation.mutateAsync({
id: agentId,
teamId: teamId,
...updatePayload,
});
};
const handleInboundAgentUpdate = async (data: AgentFormValues) => {
if (!inboundAgentId) return;
const updatePayload = {
generalPrompt: restorePromptComplexity(data.generalPrompt),
beginMessage: data.beginMessage,
language: data.language as Language,
voiceId: data.voiceId,
};
await updateAgentMutation.mutateAsync({
id: inboundAgentId,
teamId: teamId,
...updatePayload,
});
};
return (
<>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="sm:max-w-xl">
<SheetHeader showCloseButton={false} className="w-full">
<SheetTitle className="mb-6">{t("cal_ai_agent_configuration")}</SheetTitle>
<ToggleGroup
onValueChange={(val) => {
setActiveTab((val || "outgoingCalls") as "outgoingCalls" | "phoneNumber" | "incomingCalls");
}}
value={activeTab}
options={[
{ value: "outgoingCalls", label: t("outgoing_calls") },
{ value: "phoneNumber", label: t("phone_number") },
{ value: "incomingCalls", label: t("incoming_calls") },
]}
isFullWidth={true}
/>
</SheetHeader>
<SheetBody className="px-0">
{activeTab === "outgoingCalls" && (
<OutgoingCallsTab outboundAgentForm={outboundAgentForm} readOnly={readOnly} />
)}
{activeTab === "phoneNumber" && (
<PhoneNumberTab
agentData={agentData}
agentId={agentId}
readOnly={readOnly}
teamId={teamId}
workflowId={workflowId}
isOrganization={isOrganization}
form={form}
/>
)}
{activeTab === "incomingCalls" && (
<IncomingCallsTab
agentData={agentData}
inboundAgentData={inboundAgentData}
inboundAgentId={inboundAgentId}
inboundAgentForm={inboundAgentForm}
form={form}
eventTypeOptions={eventTypeOptions}
readOnly={readOnly}
teamId={teamId}
workflowStepId={workflowStepId}
/>
)}
</SheetBody>
<SheetFooter>
<div className="mr-auto">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button
color="secondary"
className="rounded-[10px]"
disabled={readOnly}
EndIcon="chevron-down">
{t("test_agent")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem
type="button"
StartIcon="monitor"
onClick={() => {
setIsWebCallDialogOpen(true);
}}>
{t("web_call")}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</div>
<Button
className="justify-center"
type="button"
color="secondary"
onClick={() => onOpenChange(false)}>
{t("cancel")}
</Button>
<Button
type="button"
className="justify-center"
onClick={() => {
if (activeTab === "incomingCalls" && inboundAgentId) {
inboundAgentForm.handleSubmit(handleInboundAgentUpdate)();
} else if (activeTab === "outgoingCalls" && agentId) {
outboundAgentForm.handleSubmit(handleAgentUpdate)();
}
}}
disabled={
readOnly ||
updateAgentMutation.isPending ||
(activeTab === "outgoingCalls" && !outboundAgentForm.formState.isDirty) ||
(activeTab === "incomingCalls" && !inboundAgentForm.formState.isDirty) ||
(activeTab === "outgoingCalls" && !agentId) ||
(activeTab === "incomingCalls" && !inboundAgentId)
}
loading={updateAgentMutation.isPending}>
{t("save")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
{activeAgentId && (
<WebCallDialog
open={isWebCallDialogOpen}
onOpenChange={setIsWebCallDialogOpen}
agentId={activeAgentId}
teamId={teamId}
isOrganization={isOrganization}
form={form}
/>
)}
{/* <ToolsDialog
open={toolDialogOpen}
onOpenChange={setToolDialogOpen}
toolDraft={toolDraft}
setToolDraft={setToolDraft}
onSave={handleToolDialogSave}
isEditing={editingToolIndex !== null}
/> */}
</>
);
}
// const ToolsDialog = ({
// open,
// onOpenChange,
// toolDraft,
// setToolDraft,
// onSave,
// isEditing,
// }: {
// open: boolean;
// onOpenChange: (open: boolean) => void;
// toolDraft: ToolDraft | null;
// setToolDraft: (draft: ToolDraft) => void;
// onSave: () => void;
// isEditing: boolean;
// }) => {
// const { t } = useLocale();
// const TOOL_TYPES = [
// { label: t("Check Availability"), value: "check_availability_cal" },
// { label: t("Book Appointment"), value: "book_appointment_cal" },
// { label: t("End Call"), value: "end_call" },
// ];
// return (
// <Dialog open={open} onOpenChange={onOpenChange}>
// <DialogContent
// enableOverflow
// title={isEditing ? t("Edit Function") : t("Add Function")}
// type="creation">
// <div className="flex flex-col gap-4">
// <div>
// <Label>{t("Type")}</Label>
// <Select
// options={TOOL_TYPES}
// value={TOOL_TYPES.find((opt) => opt.value === toolDraft?.type) || null}
// onChange={(option) => setToolDraft((d: ToolDraft) => ({ ...d, type: option?.value || "" }))}
// placeholder={t("Select function type")}
// />
// </div>
// <div>
// <Label>{t("Name")}</Label>
// <TextField
// required
// value={toolDraft?.name || ""}
// onChange={(e) => setToolDraft((d: ToolDraft) => ({ ...d, name: e.target.value }))}
// placeholder={t("Enter function name")}
// />
// </div>
// <div>
// <Label>{t("Description")}</Label>
// <TextArea
// value={toolDraft?.description || ""}
// onChange={(e) => setToolDraft((d: ToolDraft) => ({ ...d, description: e.target.value }))}
// placeholder={t("Enter description (optional)")}
// />
// </div>
// {(toolDraft?.type === "check_availability_cal" || toolDraft?.type === "book_appointment_cal") && (
// <>
// <div>
// <Label>
// {t("API Key (Cal.com)")}
// <span className="text-red-500"> *</span>
// </Label>
// <TextField
// required
// value={toolDraft?.cal_api_key || ""}
// onChange={(e) => setToolDraft((d: ToolDraft) => ({ ...d, cal_api_key: e.target.value }))}
// placeholder={t("Enter Cal.com API key")}
// />
// </div>
// <div>
// <Label>
// {t("Event Type ID (Cal.com)")}
// <span className="text-red-500"> *</span>
// </Label>
// <TextField
// required
// value={toolDraft?.event_type_id || ""}
// onChange={(e) =>
// setToolDraft((d: ToolDraft) => ({
// ...d,
// event_type_id: e.target.value ? Number(e.target.value) : null,
// }))
// }
// placeholder={t("Enter Event Type ID")}
// type="number"
// />
// </div>
// <div>
// <Label>
// {t("Timezone")}
// <span className="text-red-500"> *</span>
// </Label>
// <TextField
// required
// value={toolDraft?.timezone || ""}
// onChange={(e) => setToolDraft((d: ToolDraft) => ({ ...d, timezone: e.target.value }))}
// placeholder={t("America/Los_Angeles")}
// />
// <p className="mt-1 text-xs text-subtle">{t("Required for Cal.com calendar integration")}</p>
// </div>
// </>
// )}
// </div>
// <DialogFooter showDivider>
// <Button type="button" color="secondary" onClick={() => onOpenChange(false)}>
// {t("Cancel")}
// </Button>
// <Button type="button" onClick={onSave}>
// {t("Save")}
// </Button>
// </DialogFooter>
// </DialogContent>
// </Dialog>
// );
// };
@@ -0,0 +1,134 @@
import { CAL_AI_PHONE_NUMBER_MONTHLY_PRICE } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Alert } from "@calcom/ui/components/alert";
import { Button } from "@calcom/ui/components/button";
import { Dialog } from "@calcom/ui/components/dialog";
import { DialogContent, DialogHeader, DialogFooter as BaseDialogFooter } from "@calcom/ui/components/dialog";
import { Label } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
interface BuyNumberDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
agentId?: string | null;
workflowId?: string;
teamId?: number;
}
export function BuyNumberDialog({ open, onOpenChange, agentId, workflowId, teamId }: BuyNumberDialogProps) {
const { t } = useLocale();
const utils = trpc.useUtils();
const buyNumberMutation = trpc.viewer.phoneNumber.buy.useMutation({
onSuccess: async (data: { checkoutUrl?: string; message?: string; phoneNumber?: unknown }) => {
if (data.checkoutUrl) {
window.location.href = data.checkoutUrl;
} else if (data.phoneNumber) {
showToast(t("phone_number_purchased_successfully"), "success");
await utils.viewer.me.get.invalidate();
onOpenChange(false);
if (agentId) {
utils.viewer.aiVoiceAgent.get.invalidate({ id: agentId });
}
} else {
showToast(data.message || t("something_went_wrong"), "error");
}
},
onError: (error: { message: string }) => {
showToast(error.message, "error");
},
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent type="creation">
<DialogHeader
title={t("buy_a_new_number")}
subtitle={t("buy_number_for_x_per_month", {
priceInDollars: CAL_AI_PHONE_NUMBER_MONTHLY_PRICE,
})}
/>
<div className="flex flex-col">
<div>
<div>
<Alert
className="mb-4"
severity="info"
message={t("only_us_phone_numbers_can_be_purchased_here")}
/>
<Label>{t("supported_call_destinations")}</Label>
<div className="-mb-4 grid grid-cols-2 gap-x-8 gap-y-1 text-sm">
<div className="flex items-center gap-2">
<span className="text-xl">🇺🇸</span>
<span className="text-default">United States</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xl">🇮🇳</span>
<span className="text-default">India</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xl">🇦🇺</span>
<span className="text-default">Australia</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xl">🇩🇪</span>
<span className="text-default">Germany</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xl">🇪🇸</span>
<span className="text-default">Spain</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xl">🇬🇧</span>
<span className="text-default">United Kingdom</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xl">🇲🇽</span>
<span className="text-default">Mexico</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xl">🇫🇷</span>
<span className="text-default">France</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xl">🇯🇵</span>
<span className="text-default">Japan</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xl">🇨🇦</span>
<span className="text-default">Canada</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xl">🇮🇹</span>
<span className="text-default">Italy</span>
</div>
</div>
</div>
</div>
<BaseDialogFooter showDivider className="relative">
<Button onClick={() => onOpenChange(false)} color="secondary">
{t("close")}
</Button>
<Button
StartIcon="external-link"
onClick={() => {
if (!agentId || !workflowId) {
return;
}
buyNumberMutation.mutate({
agentId: agentId,
workflowId: workflowId,
teamId: teamId ?? undefined,
});
}}
loading={buyNumberMutation.isPending}
disabled={buyNumberMutation.isPending}>
{t("buy_us_number_button", { priceInDollars: CAL_AI_PHONE_NUMBER_MONTHLY_PRICE })}
</Button>
</BaseDialogFooter>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,93 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { ConfirmationDialogContent } from "@calcom/ui/components/dialog";
import { Dialog as UIDialog } from "@calcom/ui/components/dialog";
import { showToast } from "@calcom/ui/components/toast";
interface ConfirmationDialogsProps {
cancellingNumberId: number | null;
setCancellingNumberId: (id: number | null) => void;
numberToDelete: string | null;
setNumberToDelete: (number: string | null) => void;
agentId?: string | null;
}
export function ConfirmationDialogs({
cancellingNumberId,
setCancellingNumberId,
numberToDelete,
setNumberToDelete,
agentId,
}: ConfirmationDialogsProps) {
const { t } = useLocale();
const utils = trpc.useUtils();
const cancelSubscriptionMutation = trpc.viewer.phoneNumber.cancel.useMutation({
onSuccess: async (data: { message?: string }) => {
showToast(data.message || t("phone_number_subscription_cancelled_successfully"), "success");
setCancellingNumberId(null);
await utils.viewer.me.get.invalidate();
if (agentId) {
await utils.viewer.aiVoiceAgent.get.invalidate({ id: agentId });
}
},
onError: (error: { message: string }) => {
showToast(error.message, "error");
setCancellingNumberId(null);
},
});
const deletePhoneNumberMutation = trpc.viewer.phoneNumber.delete.useMutation({
onSuccess: async () => {
showToast(t("phone_number_deleted_successfully"), "success");
setNumberToDelete(null);
if (agentId) {
await utils.viewer.aiVoiceAgent.get.invalidate({ id: agentId });
}
},
onError: (error: { message: string }) => {
showToast(error.message, "error");
setNumberToDelete(null);
},
});
const confirmCancelSubscription = () => {
if (cancellingNumberId) {
cancelSubscriptionMutation.mutate({ phoneNumberId: cancellingNumberId });
}
};
const confirmDeletePhoneNumber = () => {
if (numberToDelete) {
deletePhoneNumberMutation.mutate({ phoneNumber: numberToDelete });
}
};
return (
<>
<UIDialog open={cancellingNumberId !== null} onOpenChange={() => setCancellingNumberId(null)}>
<ConfirmationDialogContent
isPending={cancelSubscriptionMutation.isPending}
variety="danger"
title={t("cancel_phone_number_subscription")}
confirmBtnText={t("yes_cancel_subscription")}
onConfirm={confirmCancelSubscription}>
{t("cancel_phone_number_subscription_confirmation")}
</ConfirmationDialogContent>
</UIDialog>
<UIDialog open={numberToDelete !== null} onOpenChange={() => setNumberToDelete(null)}>
<ConfirmationDialogContent
isPending={deletePhoneNumberMutation.isPending}
variety="danger"
title={t("delete_phone_number")}
confirmBtnText={t("yes_delete_phone_number")}
onConfirm={confirmDeletePhoneNumber}>
{t("delete_phone_number_confirmation")}
</ConfirmationDialogContent>
</UIDialog>
</>
);
}
@@ -0,0 +1,252 @@
import { Controller } from "react-hook-form";
import type { UseFormReturn } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { Dialog } from "@calcom/ui/components/dialog";
import { DialogContent, DialogHeader, DialogFooter as BaseDialogFooter } from "@calcom/ui/components/dialog";
import { Form, Label, Switch, TextField } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip";
import type { PhoneNumberFormValues } from "../../types/schemas";
interface ImportNumberDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
phoneNumberForm: UseFormReturn<PhoneNumberFormValues>;
showAdvancedFields: boolean;
setShowAdvancedFields: (show: boolean) => void;
agentId?: string | null;
workflowId?: string;
teamId?: number;
}
export function ImportNumberDialog({
open,
onOpenChange,
phoneNumberForm,
showAdvancedFields,
setShowAdvancedFields,
agentId,
workflowId,
teamId,
}: ImportNumberDialogProps) {
const { t } = useLocale();
const utils = trpc.useUtils();
const importNumberMutation = trpc.viewer.phoneNumber.import.useMutation({
onSuccess: async () => {
showToast(t("phone_number_imported_successfully"), "success");
onOpenChange(false);
phoneNumberForm.reset();
await utils.viewer.me.get.invalidate();
if (agentId) {
await utils.viewer.aiVoiceAgent.get.invalidate({ id: agentId });
}
},
onError: (error: { message: string }) => {
showToast(error.message, "error");
},
});
const handleImportPhoneNumber = (values: PhoneNumberFormValues) => {
if (!agentId) {
showToast(t("agent_required_for_import"), "error");
return;
}
const mutationPayload = {
...values,
workflowId: workflowId,
agentId: agentId,
teamId: teamId,
};
importNumberMutation.mutate(mutationPayload);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent enableOverflow type="creation">
<DialogHeader title={t("import_phone_number")} subtitle={t("import_phone_number_description")} />
<Form form={phoneNumberForm} handleSubmit={(values) => handleImportPhoneNumber(values)}>
<div className="space-y-6">
<Controller
name="phoneNumber"
control={phoneNumberForm.control}
render={({ field: { value } }) => (
<TextField
name="phoneNumber"
label={
<span className="flex items-center gap-1">
{t("phone_number")}
<Tooltip content={t("phone_number_info_tooltip")}>
<Icon name="info" className="text-muted h-4 w-4 cursor-pointer" />
</Tooltip>
</span>
}
value={value}
required
type="text"
placeholder="eg:- +12088782105"
onChange={(e) => {
phoneNumberForm.setValue("phoneNumber", e?.target.value, { shouldDirty: true });
}}
/>
)}
/>
<Controller
name="terminationUri"
control={phoneNumberForm.control}
render={({ field: { value } }) => (
<TextField
name="terminationUri"
label={
<span className="flex items-center gap-1">
{t("termination_uri")}
<Tooltip content={t("termination_uri_info_tooltip")}>
<Icon name="info" className="text-muted h-4 w-4 cursor-pointer" />
</Tooltip>
</span>
}
value={value}
required
type="text"
placeholder="sip.example.co.pstn.twilio.com"
onChange={(e) => {
phoneNumberForm.setValue("terminationUri", e?.target.value, { shouldDirty: true });
}}
/>
)}
/>
<div className="bg-muted rounded-xl p-1">
<div className="flex items-center justify-between p-2">
<Label className="text-emphasis mb-0 text-sm font-medium leading-none">
{t("advanced")}
</Label>
<Switch size="sm" checked={showAdvancedFields} onCheckedChange={setShowAdvancedFields} />
</div>
{showAdvancedFields && (
<div className="bg-default space-y-5 rounded-lg p-4">
<Controller
name="sipTrunkAuthUsername"
control={phoneNumberForm.control}
render={({ field: { value } }) => (
<TextField
name="sipTrunkAuthUsername"
label={
<span className="flex items-center gap-1">
{t("sip_trunk_username")}
<Tooltip content={t("sip_trunk_username_info_tooltip")}>
<Icon name="info" className="text-muted h-4 w-4 cursor-pointer" />
</Tooltip>
</span>
}
value={value}
type="text"
onChange={(e) => {
phoneNumberForm.setValue("sipTrunkAuthUsername", e?.target.value, {
shouldDirty: true,
});
}}
/>
)}
/>
<Controller
name="sipTrunkAuthPassword"
control={phoneNumberForm.control}
render={({ field: { value } }) => (
<TextField
name="sipTrunkAuthPassword"
label={
<span className="flex items-center gap-1">
{t("sip_trunk_password")}
<Tooltip content={t("sip_trunk_password_info_tooltip")}>
<Icon name="info" className="text-muted h-4 w-4 cursor-pointer" />
</Tooltip>
</span>
}
value={value}
type="password"
onChange={(e) => {
phoneNumberForm.setValue("sipTrunkAuthPassword", e?.target.value, {
shouldDirty: true,
});
}}
/>
)}
/>
<Controller
name="nickname"
control={phoneNumberForm.control}
render={({ field: { value } }) => (
<TextField
name="nickname"
label={
<span className="flex items-center gap-1">
{t("nickname")}
<Tooltip content={t("nickname_info_tooltip")}>
<Icon name="info" className="text-muted h-4 w-4 cursor-pointer" />
</Tooltip>
</span>
}
value={value}
type="text"
onChange={(e) => {
phoneNumberForm.setValue("nickname", e?.target.value, { shouldDirty: true });
}}
/>
)}
/>
</div>
)}
</div>
{/* Having trouble importing section */}
<div className="rounded-lg border p-2">
<div className="flex items-start gap-3">
<Icon name="info" className="text-subtle mt-0.5 h-5 w-5 flex-shrink-0" />
<div className="flex-1">
<p className="text-emphasis text-sm font-medium">{t("having_trouble_importing")}</p>
<p className="text-subtle mt-1 text-sm leading-tight">
{t("learn_how_to_get_your_terminator")}
</p>
</div>
<Button
type="button"
color="secondary"
size="base"
EndIcon="external-link"
href="https://cal.com/help/importing/import-numbers"
target="_blank"
className="text-emphasis my-auto">
{t("learn")}
</Button>
</div>
</div>
</div>
<BaseDialogFooter showDivider className="relative">
<Button onClick={() => onOpenChange(false)} color="secondary">
{t("cancel")}
</Button>
<Button
type="submit"
loading={importNumberMutation.isPending}
disabled={importNumberMutation.isPending}>
{t("create")}
</Button>
</BaseDialogFooter>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,176 @@
import { useRef } from "react";
import type { UseFormReturn } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { AddVariablesDropdown } from "@calcom/ui/components/editor";
import { Input, Label, TextArea } from "@calcom/ui/components/form";
import { DYNAMIC_TEXT_VARIABLES } from "../../../../lib/constants";
import type { AgentFormValues } from "../../types/schemas";
import { LanguageSelector } from "./LanguageSelector";
import { VoiceSelector } from "./VoiceSelector";
interface AgentConfigFormProps {
form: UseFormReturn<AgentFormValues>;
readOnly?: boolean;
selectedVoiceId?: string;
onVoiceDialogOpen: () => void;
}
export function AgentConfigForm({
form,
readOnly = false,
selectedVoiceId,
onVoiceDialogOpen,
}: AgentConfigFormProps) {
const { t } = useLocale();
const generalPromptRef = useRef<HTMLTextAreaElement | null>(null);
const addVariableToGeneralPrompt = (variable: string) => {
if (generalPromptRef.current) {
const currentPrompt = generalPromptRef.current.value || "";
const cursorPosition = generalPromptRef.current.selectionStart || currentPrompt.length;
const variableName = `{${variable.toUpperCase().replace(/ /g, "_")}}`;
const newPrompt = `${currentPrompt.substring(
0,
cursorPosition
)}${variableName}${currentPrompt.substring(cursorPosition)}`;
form.setValue("generalPrompt", newPrompt, { shouldDirty: true, shouldTouch: true });
requestAnimationFrame(() => {
if (generalPromptRef.current) {
generalPromptRef.current.focus();
generalPromptRef.current.setSelectionRange(
cursorPosition + variableName.length,
cursorPosition + variableName.length
);
}
});
}
};
return (
<div className="space-y-4">
<LanguageSelector control={form.control} name="language" disabled={readOnly} />
<VoiceSelector
selectedVoiceId={selectedVoiceId}
onVoiceDialogOpen={onVoiceDialogOpen}
disabled={readOnly}
/>
<div>
<Label className="text-emphasis mb-1 block text-sm font-medium">{t("initial_message")} *</Label>
<p className="text-subtle mb-1.5 text-xs">{t("initial_message_description")}</p>
<Input
type="text"
{...form.register("beginMessage")}
placeholder={t("hi_how_are_you_doing")}
disabled={readOnly}
/>
</div>
<div>
<div className="mb-1.5">
<Label className="text-emphasis mb-1 block text-sm font-medium">{t("general_prompt")} *</Label>
<p className="text-subtle text-xs">{t("general_prompt_description")}</p>
</div>
<div className="flex items-center justify-between rounded-t-lg border border-b-0 p-2">
{!readOnly && (
<AddVariablesDropdown
addVariable={addVariableToGeneralPrompt}
variables={[...DYNAMIC_TEXT_VARIABLES, "number_to_call"]}
addVariableButtonClassName="border rounded-[10px] py-1 px-1"
/>
)}
</div>
<TextArea
{...form.register("generalPrompt")}
ref={(e) => {
form.register("generalPrompt").ref(e);
generalPromptRef.current = e;
}}
placeholder={t("enter_the_general_prompt_for_the_agent")}
className="min-h-[500px] rounded-t-none"
disabled={readOnly}
/>
</div>
{/* Commented code for future tools implementation */}
{/* <div className="p-4 rounded-lg border border-subtle bg-default">
<div className="flex justify-between items-center mb-4">
<div>
<h4 className="text-sm font-medium text-emphasis">{t("Functions")}</h4>
<p className="text-xs text-subtle">
{t(
"Enable your agent with capabilities such as calendar bookings, call termination, etc."
)}
</p>
</div>
<Button
type="button"
onClick={openAddToolDialog}
color="secondary"
size="sm"
disabled={readOnly}>
<Icon name="plus" className="mr-2 w-4 h-4" />
{t("Add Function")}
</Button>
</div>
<div className="space-y-3">
{toolFields.map((tool, idx) => (
<div
key={tool.id}
className="flex justify-between items-center p-4 rounded-lg border transition-colors border-subtle bg-muted hover:bg-default">
<div className="flex gap-3">
<div className="flex justify-center items-center w-8 h-8 rounded-md border bg-default border-subtle">
<Icon name="zap" className="w-4 h-4 text-emphasis" />
</div>
<div>
<p className="font-medium text-emphasis">{tool.name}</p>
<p className="text-sm text-subtle">
{tool.type === "check_availability_cal" && t("Check Availability")}
{tool.type === "book_appointment_cal" && t("Book Appointment")}
{tool.type === "end_call" && t("End Call")}
</p>
</div>
</div>
<div className="flex gap-2 items-center">
<Button
type="button"
size="sm"
color="secondary"
variant="icon"
onClick={() => openEditToolDialog(idx)}
disabled={readOnly}>
<Icon name="pencil" className="w-4 h-4" />
</Button>
{tool.name !== "end_call" && (
<Button
type="button"
color="destructive"
variant="icon"
size="sm"
onClick={() => handleToolDelete(idx)}
disabled={readOnly}>
<Icon name="trash" className="w-4 h-4" />
</Button>
)}
</div>
</div>
))}
{toolFields.length === 0 && (
<div className="flex flex-col justify-center items-center p-6 text-center rounded-lg border border-subtle bg-muted">
<Icon name="zap" className="w-6 h-6 text-subtle" />
<p className="mt-2 text-sm text-subtle">{t("No functions configured yet")}</p>
<p className="text-xs text-subtle">
{t("Add functions to enable advanced capabilities")}
</p>
</div>
)}
</div>
</div> */}
</div>
);
}
@@ -0,0 +1,37 @@
import { Controller } from "react-hook-form";
import type { Control } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Label, Select } from "@calcom/ui/components/form";
import type { AgentFormValues } from "../../types/schemas";
import { LANGUAGE_OPTIONS } from "../../utils/constants";
interface LanguageSelectorProps {
control: Control<AgentFormValues>;
name: keyof AgentFormValues;
disabled?: boolean;
}
export function LanguageSelector({ control, name, disabled = false }: LanguageSelectorProps) {
const { t } = useLocale();
return (
<div>
<Label className="text-emphasis mb-1 block text-sm font-medium">{t("language")}</Label>
<Controller
name={name}
control={control}
render={({ field }) => (
<Select
value={LANGUAGE_OPTIONS.find((option) => option.value === field.value)}
onChange={(option) => field.onChange(option?.value)}
options={LANGUAGE_OPTIONS}
isDisabled={disabled}
className="mb-4"
/>
)}
/>
</div>
);
}
@@ -0,0 +1,30 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Label } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
interface VoiceSelectorProps {
selectedVoiceId?: string;
onVoiceDialogOpen: () => void;
disabled?: boolean;
}
export function VoiceSelector({ selectedVoiceId, onVoiceDialogOpen, disabled = false }: VoiceSelectorProps) {
const { t } = useLocale();
return (
<div>
<Label className="text-emphasis mb-1 block text-sm font-medium">{t("voice")}</Label>
<p className="text-subtle mb-1.5 text-xs">{t("select_voice_for_agent")}</p>
<Button
type="button"
color="secondary"
onClick={onVoiceDialogOpen}
disabled={disabled}
className="w-full justify-start">
<Icon name="user" className="mr-2 h-4 w-4" />
{selectedVoiceId ? <span className="text-sm">{selectedVoiceId}</span> : t("select_voice")}
</Button>
</div>
);
}
@@ -0,0 +1,190 @@
import { useState } from "react";
import type { UseFormReturn } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { Label, Select } from "@calcom/ui/components/form";
import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { showToast } from "@calcom/ui/components/toast";
import type { FormValues } from "../../../../pages/workflow";
import { VoiceSelectionDialog } from "../../../VoiceSelectionDialog";
import type { AgentFormValues } from "../../types/schemas";
import { AgentConfigForm } from "../forms/AgentConfigForm";
interface IncomingCallsTabProps {
agentData?: RouterOutputs["viewer"]["aiVoiceAgent"]["get"];
inboundAgentData?: RouterOutputs["viewer"]["aiVoiceAgent"]["get"];
inboundAgentId?: string | null;
inboundAgentForm: UseFormReturn<AgentFormValues>;
form: UseFormReturn<FormValues>;
eventTypeOptions: Option[];
readOnly?: boolean;
teamId?: number;
workflowStepId?: number;
}
export function IncomingCallsTab({
agentData,
inboundAgentData,
inboundAgentId,
inboundAgentForm,
form,
eventTypeOptions,
readOnly = false,
teamId,
workflowStepId,
}: IncomingCallsTabProps) {
const { t } = useLocale();
const utils = trpc.useUtils();
const [isVoiceDialogOpen, setIsVoiceDialogOpen] = useState(false);
const setupInboundAgentMutation = trpc.viewer.aiVoiceAgent.setupInboundAgent.useMutation({
onSuccess: async (data) => {
showToast(t("inbound_agent_setup_success"), "success");
// Update the form with the new inbound agent ID
if (data.agentId && form && workflowStepId) {
const stepIndex = form.getValues("steps")?.findIndex((s) => s.id === workflowStepId);
if (stepIndex !== undefined && stepIndex !== -1) {
form.setValue(`steps.${stepIndex}.inboundAgentId`, data.agentId, { shouldDirty: true });
}
}
// Invalidate and refetch the agent data
if (data.agentId) {
await utils.viewer.aiVoiceAgent.get.invalidate({ id: data.agentId });
}
},
onError: (error: { message: string }) => {
showToast(error.message, "error");
},
});
const updateInboundAgentEventTypeMutation =
trpc.viewer.aiVoiceAgent.updateInboundAgentEventType.useMutation({
onSuccess: async () => {
showToast(t("agent_event_type_updated_successfully"), "success");
if (inboundAgentId) {
await utils.viewer.aiVoiceAgent.get.invalidate({ id: inboundAgentId });
}
},
onError: (error: { message: string }) => {
showToast(error.message, "error");
},
});
const hasActivePhoneNumbers =
agentData?.outboundPhoneNumbers &&
agentData.outboundPhoneNumbers.filter(
(phone) =>
phone.subscriptionStatus === PhoneNumberSubscriptionStatus.ACTIVE || !phone.subscriptionStatus
).length > 0;
if (!hasActivePhoneNumbers) {
return (
<div className="border-subtle rounded-xl border p-8">
<div className="flex flex-col items-center space-y-6 text-center">
<div className="bg-muted flex h-16 w-16 items-center justify-center rounded-lg">
<Icon name="info" className="text-subtle h-8 w-8" />
</div>
<div className="space-y-2">
<h3 className="text-emphasis text-lg font-semibold">{t("setup_incoming_agent")}</h3>
<p className="text-subtle text-sm">{t("connect_a_phone_number_first")}</p>
</div>
</div>
</div>
);
}
if (!inboundAgentId) {
return (
<div className="border-subtle rounded-xl border p-8">
<div className="flex flex-col items-center space-y-6 text-center">
<div className="bg-muted flex h-16 w-16 items-center justify-center rounded-lg">
<Icon name="phone-incoming" className="text-subtle h-8 w-8" />
</div>
<div className="space-y-2">
<h3 className="text-emphasis text-lg font-semibold">{t("setup_inbound_agent")}</h3>
<p className="text-subtle text-sm">{t("setup_inbound_agent_for_incoming_calls")}</p>
</div>
<Button
onClick={() => {
const phoneNumber = agentData?.outboundPhoneNumbers?.filter(
(phone) =>
phone.subscriptionStatus === PhoneNumberSubscriptionStatus.ACTIVE ||
!phone.subscriptionStatus
)[0]?.phoneNumber;
if (!phoneNumber) {
showToast(t("no_active_phone_number_available"), "error");
return;
}
if (!workflowStepId) {
showToast(t("workflow_step_not_configured"), "error");
return;
}
setupInboundAgentMutation.mutate({
phoneNumber,
teamId,
workflowStepId: workflowStepId,
});
}}
className="px-6"
loading={setupInboundAgentMutation.isPending}
disabled={setupInboundAgentMutation.isPending || !workflowStepId}>
{t("setup_inbound_agent")}
</Button>
</div>
</div>
);
}
return (
<div className="space-y-4">
<div>
<Label className="text-emphasis mb-1 block text-sm font-medium">{t("event_type")}</Label>
<p className="text-subtle mb-1.5 text-xs">{t("select_event_type_for_inbound_calls")}</p>
<Select
isSearchable={false}
innerClassNames={{ valueContainer: "font-medium" }}
className="text-sm font-medium"
isDisabled={readOnly || updateInboundAgentEventTypeMutation.isPending}
isLoading={updateInboundAgentEventTypeMutation.isPending}
onChange={(val) => {
if (val && inboundAgentId) {
updateInboundAgentEventTypeMutation.mutate({
agentId: inboundAgentId,
eventTypeId: parseInt(val.value),
teamId,
});
}
}}
value={eventTypeOptions.find((opt) => opt.value === inboundAgentData?.inboundEventTypeId?.toString())}
options={eventTypeOptions}
placeholder={t("select_event_type")}
/>
</div>
<AgentConfigForm
form={inboundAgentForm}
readOnly={readOnly}
selectedVoiceId={inboundAgentForm.watch("voiceId")}
onVoiceDialogOpen={() => setIsVoiceDialogOpen(true)}
/>
<VoiceSelectionDialog
open={isVoiceDialogOpen}
onOpenChange={setIsVoiceDialogOpen}
selectedVoiceId={inboundAgentForm.watch("voiceId")}
onVoiceSelect={(voiceId: string) => {
inboundAgentForm.setValue("voiceId", voiceId, { shouldDirty: true });
}}
/>
</div>
);
}
@@ -0,0 +1,35 @@
import { useState } from "react";
import type { UseFormReturn } from "react-hook-form";
import { VoiceSelectionDialog } from "../../../VoiceSelectionDialog";
import type { AgentFormValues } from "../../types/schemas";
import { AgentConfigForm } from "../forms/AgentConfigForm";
interface OutgoingCallsTabProps {
outboundAgentForm: UseFormReturn<AgentFormValues>;
readOnly?: boolean;
}
export function OutgoingCallsTab({ outboundAgentForm, readOnly = false }: OutgoingCallsTabProps) {
const [isVoiceDialogOpen, setIsVoiceDialogOpen] = useState(false);
return (
<>
<AgentConfigForm
form={outboundAgentForm}
readOnly={readOnly}
selectedVoiceId={outboundAgentForm.watch("voiceId")}
onVoiceDialogOpen={() => setIsVoiceDialogOpen(true)}
/>
<VoiceSelectionDialog
open={isVoiceDialogOpen}
onOpenChange={setIsVoiceDialogOpen}
selectedVoiceId={outboundAgentForm.watch("voiceId")}
onVoiceSelect={(voiceId: string) => {
outboundAgentForm.setValue("voiceId", voiceId, { shouldDirty: true });
}}
/>
</>
);
}
@@ -0,0 +1,350 @@
import { useState } from "react";
import type { UseFormReturn } from "react-hook-form";
import { CAL_AI_PHONE_NUMBER_MONTHLY_PRICE } from "@calcom/lib/constants";
import { formatPhoneNumber } from "@calcom/lib/formatPhoneNumber";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import {
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@calcom/ui/components/dropdown";
import { Icon } from "@calcom/ui/components/icon";
import { showToast } from "@calcom/ui/components/toast";
import type { FormValues } from "../../../../pages/workflow";
import { TestPhoneCallDialog } from "../../../TestPhoneCallDialog";
import { WebCallDialog } from "../../../WebCallDialog";
import { usePhoneNumberActions } from "../../hooks/usePhoneNumberActions";
import { BuyNumberDialog } from "../dialogs/BuyNumberDialog";
import { ConfirmationDialogs } from "../dialogs/ConfirmationDialogs";
import { ImportNumberDialog } from "../dialogs/ImportNumberDialog";
interface PhoneNumberTabProps {
agentData?: RouterOutputs["viewer"]["aiVoiceAgent"]["get"];
readOnly?: boolean;
agentId?: string | null;
teamId?: number;
workflowId?: string;
isOrganization?: boolean;
form?: UseFormReturn<FormValues>;
}
export function PhoneNumberTab({
agentData,
readOnly = false,
agentId,
teamId,
workflowId,
isOrganization = false,
form,
}: PhoneNumberTabProps) {
const { t } = useLocale();
const utils = trpc.useUtils();
const [isBuyDialogOpen, setIsBuyDialogOpen] = useState(false);
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
const [showAdvancedFields, setShowAdvancedFields] = useState(false);
const [isTestAgentDialogOpen, setIsTestAgentDialogOpen] = useState(false);
const [isWebCallDialogOpen, setIsWebCallDialogOpen] = useState(false);
const phoneNumberActions = usePhoneNumberActions();
const buyNumberMutation = trpc.viewer.phoneNumber.buy.useMutation({
onSuccess: async (data: { checkoutUrl?: string; message?: string; phoneNumber?: unknown }) => {
if (data.checkoutUrl) {
window.location.href = data.checkoutUrl;
} else if (data.phoneNumber) {
showToast(t("phone_number_purchased_successfully"), "success");
await utils.viewer.me.get.invalidate();
setIsBuyDialogOpen(false);
if (agentId) {
utils.viewer.aiVoiceAgent.get.invalidate({ id: agentId });
}
} else {
showToast(data.message || t("something_went_wrong"), "error");
}
},
onError: (error: { message: string }) => {
showToast(error.message, "error");
},
});
const importNumberMutation = trpc.viewer.phoneNumber.import.useMutation({
onSuccess: async () => {
showToast(t("phone_number_imported_successfully"), "success");
setIsImportDialogOpen(false);
await utils.viewer.me.get.invalidate();
if (agentId) {
await utils.viewer.aiVoiceAgent.get.invalidate({ id: agentId });
}
},
onError: (error: { message: string }) => {
showToast(error.message, "error");
},
});
const updateAgentMutation = trpc.viewer.aiVoiceAgent.update.useMutation({
onSuccess: async () => {
if (agentId) {
await utils.viewer.aiVoiceAgent.get.invalidate({ id: agentId });
}
},
onError: (error: { message: string }) => {
showToast(error.message, "error");
},
});
const hasActivePhoneNumbers =
agentData?.outboundPhoneNumbers &&
agentData.outboundPhoneNumbers.filter(
(phone) =>
phone.subscriptionStatus === PhoneNumberSubscriptionStatus.ACTIVE || !phone.subscriptionStatus
).length > 0;
if (hasActivePhoneNumbers) {
const activePhoneNumbers = agentData.outboundPhoneNumbers.filter(
(phone) =>
phone.subscriptionStatus === PhoneNumberSubscriptionStatus.ACTIVE || !phone.subscriptionStatus
);
const phoneNumber = activePhoneNumbers[0];
return (
<>
<div className="relative space-y-2">
{updateAgentMutation.isPending && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-white/50">
<div className="flex items-center gap-2 text-sm text-gray-600">
<Icon name="loader" className="h-4 w-4 animate-spin" />
{t("updating")}...
</div>
</div>
)}
<div className="border-subtle rounded-lg border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-emphasis text-sm">{formatPhoneNumber(phoneNumber.phoneNumber)}</span>
<Badge variant="green" size="md" withDot>
{t("active")}
</Badge>
{phoneNumber.subscriptionStatus && (
<span className="text-muted text-xs">
{t("phone_number_cost", {
price: CAL_AI_PHONE_NUMBER_MONTHLY_PRICE,
})}
</span>
)}
</div>
</div>
<div className="flex items-center gap-1">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button
color="secondary"
className="rounded-[10px]"
disabled={readOnly}
EndIcon="chevron-down">
{t("test_agent")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem
type="button"
StartIcon="phone"
onClick={() => {
setIsTestAgentDialogOpen(true);
}}>
{t("phone_call")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem
type="button"
StartIcon="monitor"
onClick={() => {
setIsWebCallDialogOpen(true);
}}>
{t("web_call")}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
{!readOnly && (
<Dropdown>
<DropdownMenuTrigger asChild>
<Button type="button" color="secondary" variant="icon" StartIcon="ellipsis" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem
type="button"
StartIcon="trash"
color="destructive"
onClick={() => {
if (readOnly) return;
if (phoneNumber.subscriptionStatus === PhoneNumberSubscriptionStatus.ACTIVE) {
phoneNumberActions.handleCancelSubscription(phoneNumber.id);
} else {
phoneNumberActions.handleDeletePhoneNumber(phoneNumber.phoneNumber);
}
}}>
{phoneNumber.subscriptionStatus === PhoneNumberSubscriptionStatus.ACTIVE
? t("unsubscribe")
: t("delete")}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
)}
</div>
</div>
</div>
</div>
<BuyNumberDialog
open={isBuyDialogOpen}
onOpenChange={setIsBuyDialogOpen}
agentId={agentId}
workflowId={workflowId}
teamId={teamId}
/>
<ImportNumberDialog
open={isImportDialogOpen}
onOpenChange={setIsImportDialogOpen}
phoneNumberForm={phoneNumberActions.phoneNumberForm}
showAdvancedFields={showAdvancedFields}
setShowAdvancedFields={setShowAdvancedFields}
agentId={agentId}
workflowId={workflowId}
teamId={teamId}
/>
{agentId && form && (
<TestPhoneCallDialog
open={isTestAgentDialogOpen}
onOpenChange={setIsTestAgentDialogOpen}
agentId={agentId}
teamId={teamId}
form={form}
/>
)}
{agentId && form && (
<WebCallDialog
open={isWebCallDialogOpen}
onOpenChange={setIsWebCallDialogOpen}
agentId={agentId}
teamId={teamId}
isOrganization={isOrganization}
form={form}
/>
)}
<ConfirmationDialogs
cancellingNumberId={phoneNumberActions.cancellingNumberId}
setCancellingNumberId={phoneNumberActions.setCancellingNumberId}
numberToDelete={phoneNumberActions.numberToDelete}
setNumberToDelete={phoneNumberActions.setNumberToDelete}
agentId={agentId}
/>
</>
);
}
return (
<>
<div className="border-subtle rounded-xl border p-8">
<div className="flex flex-col items-center space-y-6 text-center">
<div className="bg-muted flex h-16 w-16 items-center justify-center rounded-lg">
<Icon name="phone" className="text-subtle h-8 w-8" />
</div>
<div className="space-y-2">
<h3 className="text-emphasis text-lg font-semibold">{t("no_phone_numbers")}</h3>
<p className="text-subtle text-sm">{t("buy_a_phone_number_or_import_one_you_already_have")}</p>
</div>
<div className="flex gap-3">
<Button
onClick={() => {
if (readOnly) return;
setIsBuyDialogOpen(true);
}}
StartIcon="external-link"
className="px-6"
disabled={readOnly || buyNumberMutation.isPending}>
{t("buy")}
</Button>
<Button
onClick={() => {
if (readOnly) return;
setIsImportDialogOpen(true);
}}
color="secondary"
className="px-6"
disabled={readOnly || importNumberMutation.isPending}>
{t("import")}
</Button>
</div>
</div>
</div>
<BuyNumberDialog
open={isBuyDialogOpen}
onOpenChange={setIsBuyDialogOpen}
agentId={agentId}
workflowId={workflowId}
teamId={teamId}
/>
<ImportNumberDialog
open={isImportDialogOpen}
onOpenChange={setIsImportDialogOpen}
phoneNumberForm={phoneNumberActions.phoneNumberForm}
showAdvancedFields={showAdvancedFields}
setShowAdvancedFields={setShowAdvancedFields}
agentId={agentId}
workflowId={workflowId}
teamId={teamId}
/>
{agentId && form && (
<TestPhoneCallDialog
open={isTestAgentDialogOpen}
onOpenChange={setIsTestAgentDialogOpen}
agentId={agentId}
teamId={teamId}
form={form}
/>
)}
{agentId && form && (
<WebCallDialog
open={isWebCallDialogOpen}
onOpenChange={setIsWebCallDialogOpen}
agentId={agentId}
teamId={teamId}
isOrganization={isOrganization}
form={form}
/>
)}
<ConfirmationDialogs
cancellingNumberId={phoneNumberActions.cancellingNumberId}
setCancellingNumberId={phoneNumberActions.setCancellingNumberId}
numberToDelete={phoneNumberActions.numberToDelete}
setNumberToDelete={phoneNumberActions.setNumberToDelete}
agentId={agentId}
/>
</>
);
}
@@ -0,0 +1,74 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import type { RouterOutputs } from "@calcom/trpc/react";
import type { AgentFormValues } from "../types/schemas";
import { agentSchema } from "../types/schemas";
import { cleanPromptForDisplay } from "../utils/promptUtils";
interface UseAgentFormsProps {
agentData?: RouterOutputs["viewer"]["aiVoiceAgent"]["get"];
inboundAgentData?: RouterOutputs["viewer"]["aiVoiceAgent"]["get"];
}
export function useAgentForms({ agentData, inboundAgentData }: UseAgentFormsProps) {
const outboundAgentForm = useForm<AgentFormValues>({
resolver: zodResolver(agentSchema),
defaultValues: {
generalPrompt: "",
beginMessage: "",
numberToCall: "",
language: "en-US",
voiceId: "",
generalTools: [],
},
});
const inboundAgentForm = useForm<AgentFormValues>({
resolver: zodResolver(agentSchema),
defaultValues: {
generalPrompt: "",
beginMessage: "",
numberToCall: "",
language: "en-US",
voiceId: "",
generalTools: [],
},
});
useEffect(() => {
if (agentData?.retellData) {
const retellData = agentData.retellData;
outboundAgentForm.reset({
generalPrompt: cleanPromptForDisplay(retellData.generalPrompt || ""),
beginMessage: retellData.beginMessage || "",
numberToCall: "",
language: retellData.language || "en-US",
voiceId: retellData.voiceId || "",
generalTools: retellData.generalTools || [],
});
}
}, [agentData, outboundAgentForm]);
useEffect(() => {
if (inboundAgentData?.retellData) {
const retellData = inboundAgentData.retellData;
inboundAgentForm.reset({
generalPrompt: cleanPromptForDisplay(retellData.generalPrompt || ""),
beginMessage: retellData.beginMessage || "",
numberToCall: "",
language: retellData.language || "en-US",
voiceId: retellData.voiceId || "",
generalTools: retellData.generalTools || [],
});
}
}, [inboundAgentData, inboundAgentForm]);
return {
outboundAgentForm,
inboundAgentForm,
};
}
@@ -0,0 +1,40 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
import type { PhoneNumberFormValues } from "../types/schemas";
import { phoneNumberFormSchema } from "../types/schemas";
export function usePhoneNumberActions() {
const [cancellingNumberId, setCancellingNumberId] = useState<number | null>(null);
const [numberToDelete, setNumberToDelete] = useState<string | null>(null);
const phoneNumberForm = useForm<PhoneNumberFormValues>({
resolver: zodResolver(phoneNumberFormSchema),
defaultValues: {
phoneNumber: "",
terminationUri: "",
sipTrunkAuthUsername: "",
sipTrunkAuthPassword: "",
nickname: "",
},
});
const handleCancelSubscription = (phoneNumberId: number) => {
setCancellingNumberId(phoneNumberId);
};
const handleDeletePhoneNumber = (phoneNumber: string) => {
setNumberToDelete(phoneNumber);
};
return {
phoneNumberForm,
cancellingNumberId,
setCancellingNumberId,
numberToDelete,
setNumberToDelete,
handleCancelSubscription,
handleDeletePhoneNumber,
};
}
@@ -0,0 +1,45 @@
import { isValidPhoneNumber } from "libphonenumber-js";
import { z } from "zod";
export const agentSchema = z.object({
generalPrompt: z.string().min(1, "General prompt is required"),
beginMessage: z.string().min(1, "Begin message is required"),
numberToCall: z.string().optional(),
language: z.string().optional(),
voiceId: z.string().optional(),
generalTools: z
.array(
z.object({
type: z.string(),
name: z.string(),
description: z.string().nullish().default(null),
cal_api_key: z.string().nullish().default(null),
event_type_id: z.number().nullish().default(null),
timezone: z.string().nullish().default(null),
})
)
.optional(),
});
export const phoneNumberFormSchema = z.object({
phoneNumber: z.string().refine((val) => isValidPhoneNumber(val)),
terminationUri: z.string().min(1, "Termination URI is required"),
sipTrunkAuthUsername: z.string().optional(),
sipTrunkAuthPassword: z.string().optional(),
nickname: z.string().optional(),
});
export type AgentFormValues = z.infer<typeof agentSchema>;
export type PhoneNumberFormValues = z.infer<typeof phoneNumberFormSchema>;
// Commented types for future implementation
// type RetellData = RouterOutputs["viewer"]["ai"]["get"]["retellData"];
// type ToolDraft = {
// type: string;
// name: string;
// description: string | null;
// cal_api_key: string | null;
// event_type_id: number | null;
// timezone: string;
// };
@@ -0,0 +1,38 @@
export const LANGUAGE_OPTIONS = [
{ value: "en-US", label: "English (US)" },
{ value: "en-IN", label: "English (India)" },
{ value: "en-GB", label: "English (UK)" },
{ value: "en-AU", label: "English (Australia)" },
{ value: "en-NZ", label: "English (New Zealand)" },
{ value: "de-DE", label: "German" },
{ value: "es-ES", label: "Spanish (Spain)" },
{ value: "es-419", label: "Spanish (Latin America)" },
{ value: "hi-IN", label: "Hindi" },
{ value: "fr-FR", label: "French (France)" },
{ value: "fr-CA", label: "French (Canada)" },
{ value: "ja-JP", label: "Japanese" },
{ value: "pt-PT", label: "Portuguese (Portugal)" },
{ value: "pt-BR", label: "Portuguese (Brazil)" },
{ value: "zh-CN", label: "Chinese (Simplified)" },
{ value: "ru-RU", label: "Russian" },
{ value: "it-IT", label: "Italian" },
{ value: "ko-KR", label: "Korean" },
{ value: "nl-NL", label: "Dutch (Netherlands)" },
{ value: "nl-BE", label: "Dutch (Belgium)" },
{ value: "pl-PL", label: "Polish" },
{ value: "tr-TR", label: "Turkish" },
{ value: "th-TH", label: "Thai" },
{ value: "vi-VN", label: "Vietnamese" },
{ value: "ro-RO", label: "Romanian" },
{ value: "bg-BG", label: "Bulgarian" },
{ value: "ca-ES", label: "Catalan" },
{ value: "da-DK", label: "Danish" },
{ value: "fi-FI", label: "Finnish" },
{ value: "el-GR", label: "Greek" },
{ value: "hu-HU", label: "Hungarian" },
{ value: "id-ID", label: "Indonesian" },
{ value: "no-NO", label: "Norwegian" },
{ value: "sk-SK", label: "Slovak" },
{ value: "sv-SE", label: "Swedish" },
{ value: "multi", label: "Multilingual" },
];
@@ -0,0 +1,40 @@
// Utility functions for prompt display and restoration
import { RETELL_AI_TEST_MODE, RETELL_AI_TEST_EVENT_TYPE_MAP } from "@calcom/lib/constants";
export const cleanPromptForDisplay = (prompt: string): string => {
if (!prompt) return prompt;
const cleanedPrompt = prompt
.replace(/check_availability_\{\{eventTypeId\}\}/g, "check_availability")
.replace(/book_appointment_\{\{eventTypeId\}\}/g, "book_appointment")
.replace(/check_availability_\d+/g, "check_availability")
.replace(/book_appointment_\d+/g, "book_appointment");
return cleanedPrompt;
};
export const restorePromptComplexity = (prompt: string): string => {
if (!prompt) return prompt;
return prompt
.replace(/\bcheck_availability\b/g, "check_availability_{{eventTypeId}}")
.replace(/\bbook_appointment\b/g, "book_appointment_{{eventTypeId}}");
};
/**
* Replaces event type placeholders in a prompt with the actual event type ID
* Handles both numeric placeholders (check_availability_123) and template placeholders (check_availability_{{eventTypeId}})
*/
export const replaceEventTypePlaceholders = (prompt: string, eventTypeId: number): string => {
let eventTypeIdToUse = eventTypeId;
if (RETELL_AI_TEST_MODE && RETELL_AI_TEST_EVENT_TYPE_MAP) {
const mappedId = RETELL_AI_TEST_EVENT_TYPE_MAP[String(eventTypeId)];
eventTypeIdToUse = mappedId ? Number(mappedId) : eventTypeId;
}
return prompt
.replace(/check_availability_\d+/g, `check_availability_${eventTypeIdToUse}`)
.replace(/book_appointment_\d+/g, `book_appointment_${eventTypeIdToUse}`)
.replace(/check_availability_\{\{eventTypeId\}\}/g, `check_availability_${eventTypeIdToUse}`)
.replace(/book_appointment_\{\{eventTypeId\}\}/g, `book_appointment_${eventTypeIdToUse}`);
};
@@ -0,0 +1,107 @@
import { useMemo } from "react";
import type { UseFormReturn } from "react-hook-form";
import { useWatch } from "react-hook-form";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import type { FormValues } from "../pages/workflow";
type AgentData = RouterOutputs["viewer"]["aiVoiceAgent"]["get"];
interface AgentQuery {
data: AgentData | undefined;
isPending: boolean;
isLoading: boolean;
error: Error | null;
}
interface UseAgentsDataReturn {
outboundAgentQueries: AgentQuery[];
inboundAgentQueries: AgentQuery[];
}
const emptyQuery: AgentQuery = { data: undefined, isPending: false, isLoading: false, error: null };
/**
* Custom hook to fetch and manage agent data for workflow steps.
*
* @param form - React Hook Form instance for workflow form values
* @returns Object containing outbound and inbound agent queries with proper typing
*/
export function useAgentsData(form: UseFormReturn<FormValues>): UseAgentsDataReturn {
const watchedSteps = useWatch({
control: form.control,
name: "steps",
});
const allAgentIds = useMemo(() => {
const outboundAgentIds = (watchedSteps || []).map((step) => step?.agentId || null);
const inboundAgentIds = (watchedSteps || []).map((step) => step?.inboundAgentId || null);
return { outboundAgentIds, inboundAgentIds };
}, [watchedSteps]);
const uniqueAgentIds = useMemo((): string[] => {
const allIds = [...allAgentIds.outboundAgentIds, ...allAgentIds.inboundAgentIds];
const validIds: string[] = [];
for (const id of allIds) {
if (id && typeof id === "string") {
validIds.push(id);
}
}
return Array.from(new Set(validIds));
}, [allAgentIds]);
const uniqueAgentQueries = trpc.useQueries((t) =>
uniqueAgentIds.map((agentId) => {
return t.viewer.aiVoiceAgent.get(
{ id: agentId },
{
enabled: true,
staleTime: 5 * 60 * 1000,
}
);
})
);
const agentDataMap = useMemo(() => {
const map = new Map<string, AgentQuery>();
uniqueAgentIds.forEach((agentId, index) => {
const query = uniqueAgentQueries[index];
if (query) {
map.set(agentId, {
data: query.data,
isPending: query.isPending,
isLoading: query.isLoading,
error: query.error,
});
}
});
return map;
}, [uniqueAgentIds, uniqueAgentQueries]);
const outboundAgentQueries = useMemo((): AgentQuery[] => {
return allAgentIds.outboundAgentIds.map((agentId) => {
if (!agentId) {
return emptyQuery;
}
return agentDataMap.get(agentId) || emptyQuery;
});
}, [allAgentIds.outboundAgentIds, agentDataMap]);
const inboundAgentQueries = useMemo((): AgentQuery[] => {
return allAgentIds.inboundAgentIds.map((agentId) => {
if (!agentId) {
return emptyQuery;
}
return agentDataMap.get(agentId) || emptyQuery;
});
}, [allAgentIds.inboundAgentIds, agentDataMap]);
return {
outboundAgentQueries,
inboundAgentQueries,
};
}
@@ -38,7 +38,11 @@ import { getTranslatedText, translateVariablesToEnglish } from "../lib/variableT
export type FormValues = {
name: string;
activeOn: Option[];
steps: (WorkflowStep & { senderName: string | null; agentId?: string | null })[];
steps: (WorkflowStep & {
senderName: string | null;
agentId?: string | null;
inboundAgentId?: string | null;
})[];
trigger: WorkflowTriggerEvents;
time?: number;
timeUnit?: TimeUnit;
@@ -1,5 +1,4 @@
import prisma from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
import { Prisma, PrismaClient } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
interface _AgentRawResult {
@@ -9,6 +8,7 @@ interface _AgentRawResult {
enabled: boolean;
userId: number;
teamId: number | null;
inboundEventTypeId?: number | null;
createdAt: Date;
updatedAt: Date;
user_id?: number;
@@ -29,8 +29,10 @@ interface _PhoneNumberRawResult {
}
export class PrismaAgentRepository {
private static async getUserAccessibleTeamIds(userId: number): Promise<number[]> {
const memberships = await prisma.membership.findMany({
constructor(private prismaClient: PrismaClient) {}
private async getUserAccessibleTeamIds(userId: number): Promise<number[]> {
const memberships = await this.prismaClient.membership.findMany({
where: {
userId,
accepted: true,
@@ -43,8 +45,8 @@ export class PrismaAgentRepository {
return memberships.map((membership) => membership.teamId);
}
private static async getUserAdminTeamIds(userId: number): Promise<number[]> {
const memberships = await prisma.membership.findMany({
private async getUserAdminTeamIds(userId: number): Promise<number[]> {
const memberships = await this.prismaClient.membership.findMany({
where: {
userId,
accepted: true,
@@ -60,7 +62,7 @@ export class PrismaAgentRepository {
return memberships.map((membership) => membership.teamId);
}
static async findByIdWithUserAccess({
async findByIdWithUserAccess({
agentId,
userId,
teamId,
@@ -98,6 +100,7 @@ export class PrismaAgentRepository {
enabled,
"userId",
"teamId",
"inboundEventTypeId",
"createdAt",
"updatedAt"
FROM "Agent"
@@ -105,12 +108,12 @@ export class PrismaAgentRepository {
LIMIT 1
`;
const agents = await prisma.$queryRaw<_AgentRawResult[]>(query);
const agents = await this.prismaClient.$queryRaw<_AgentRawResult[]>(query);
return agents.length > 0 ? agents[0] : null;
}
static async findByProviderAgentIdWithUserAccess({
async findByProviderAgentIdWithUserAccess({
providerAgentId,
userId,
}: {
@@ -136,6 +139,7 @@ export class PrismaAgentRepository {
enabled,
"userId",
"teamId",
"inboundEventTypeId",
"createdAt",
"updatedAt"
FROM "Agent"
@@ -143,13 +147,13 @@ export class PrismaAgentRepository {
LIMIT 1
`;
const agents = await prisma.$queryRaw<_AgentRawResult[]>(query);
const agents = await this.prismaClient.$queryRaw<_AgentRawResult[]>(query);
return agents.length > 0 ? agents[0] : null;
}
static async findById({ id }: { id: string }) {
return await prisma.agent.findUnique({
async findById({ id }: { id: string }) {
return await this.prismaClient.agent.findUnique({
select: {
id: true,
name: true,
@@ -157,6 +161,7 @@ export class PrismaAgentRepository {
enabled: true,
userId: true,
teamId: true,
inboundEventTypeId: true,
createdAt: true,
updatedAt: true,
},
@@ -166,8 +171,8 @@ export class PrismaAgentRepository {
});
}
static async findByProviderAgentId({ providerAgentId }: { providerAgentId: string }) {
return await prisma.agent.findUnique({
async findByProviderAgentId({ providerAgentId }: { providerAgentId: string }) {
return await this.prismaClient.agent.findUnique({
select: {
id: true,
name: true,
@@ -175,6 +180,7 @@ export class PrismaAgentRepository {
enabled: true,
userId: true,
teamId: true,
inboundEventTypeId: true,
createdAt: true,
updatedAt: true,
},
@@ -184,7 +190,7 @@ export class PrismaAgentRepository {
});
}
static async findManyWithUserAccess({
async findManyWithUserAccess({
userId,
teamId,
scope = "all",
@@ -239,6 +245,7 @@ export class PrismaAgentRepository {
a.enabled,
a."userId",
a."teamId",
a."inboundEventTypeId",
a."createdAt",
a."updatedAt",
u.id as user_id,
@@ -255,13 +262,13 @@ export class PrismaAgentRepository {
ORDER BY a."teamId" ASC, a."createdAt" DESC
`;
const agents = await prisma.$queryRaw<_AgentRawResult[]>(query);
const agents = await this.prismaClient.$queryRaw<_AgentRawResult[]>(query);
// Get phone numbers for each agent in a separate query to avoid N+1
const agentIds = agents.map((agent) => agent.id);
const phoneNumbers =
agentIds.length > 0
? await prisma.$queryRaw<_PhoneNumberRawResult[]>`
? await this.prismaClient.$queryRaw<_PhoneNumberRawResult[]>`
SELECT
pn.id,
pn."phoneNumber",
@@ -298,6 +305,7 @@ export class PrismaAgentRepository {
enabled: agent.enabled,
userId: agent.userId,
teamId: agent.teamId,
inboundEventTypeId: agent.inboundEventTypeId,
createdAt: agent.createdAt,
updatedAt: agent.updatedAt,
user: agent.user_id
@@ -319,7 +327,7 @@ export class PrismaAgentRepository {
}));
}
static async findByIdWithUserAccessAndDetails({
async findByIdWithUserAccessAndDetails({
id,
userId,
teamId,
@@ -357,6 +365,7 @@ export class PrismaAgentRepository {
a.enabled,
a."userId",
a."teamId",
a."inboundEventTypeId",
a."createdAt",
a."updatedAt",
u.id as user_id,
@@ -372,7 +381,7 @@ export class PrismaAgentRepository {
LIMIT 1
`;
const agents = await prisma.$queryRaw<_AgentRawResult[]>(query);
const agents = await this.prismaClient.$queryRaw<_AgentRawResult[]>(query);
if (agents.length === 0) {
return null;
@@ -380,7 +389,7 @@ export class PrismaAgentRepository {
const agent = agents[0];
const phoneNumbers = await prisma.$queryRaw<_PhoneNumberRawResult[]>`
const phoneNumbers = await this.prismaClient.$queryRaw<_PhoneNumberRawResult[]>`
SELECT
pn.id,
pn."phoneNumber",
@@ -398,6 +407,7 @@ export class PrismaAgentRepository {
enabled: agent.enabled,
userId: agent.userId,
teamId: agent.teamId,
inboundEventTypeId: agent.inboundEventTypeId,
createdAt: agent.createdAt,
updatedAt: agent.updatedAt,
user: agent.user_id
@@ -423,7 +433,7 @@ export class PrismaAgentRepository {
};
}
static async create({
async create({
name,
providerAgentId,
userId,
@@ -434,7 +444,7 @@ export class PrismaAgentRepository {
userId: number;
teamId?: number;
}) {
return await prisma.agent.create({
return await this.prismaClient.agent.create({
data: {
name,
providerAgentId,
@@ -444,7 +454,7 @@ export class PrismaAgentRepository {
});
}
static async findByIdWithAdminAccess({
async findByIdWithAdminAccess({
id,
userId,
teamId,
@@ -481,18 +491,19 @@ export class PrismaAgentRepository {
"userId",
"teamId",
"createdAt",
"updatedAt"
"updatedAt",
"inboundEventTypeId"
FROM "Agent"
WHERE ${whereCondition}
LIMIT 1
`;
const agents = await prisma.$queryRaw<_AgentRawResult[]>(query);
const agents = await this.prismaClient.$queryRaw<_AgentRawResult[]>(query);
return agents.length > 0 ? agents[0] : null;
}
static async findByIdWithCallAccess({ id, userId }: { id: string; userId: number }) {
async findByIdWithCallAccess({ id, userId }: { id: string; userId: number }) {
const accessibleTeamIds = await this.getUserAccessibleTeamIds(userId);
let whereCondition: Prisma.Sql;
@@ -512,6 +523,7 @@ export class PrismaAgentRepository {
a.enabled,
a."userId",
a."teamId",
a."inboundEventTypeId",
a."createdAt",
a."updatedAt"
FROM "Agent" a
@@ -519,7 +531,7 @@ export class PrismaAgentRepository {
LIMIT 1
`;
const agents = await prisma.$queryRaw<_AgentRawResult[]>(query);
const agents = await this.prismaClient.$queryRaw<_AgentRawResult[]>(query);
if (agents.length === 0) {
return null;
@@ -527,7 +539,7 @@ export class PrismaAgentRepository {
const agent = agents[0];
const phoneNumbers = await prisma.$queryRaw<{ phoneNumber: string }[]>`
const phoneNumbers = await this.prismaClient.$queryRaw<{ phoneNumber: string }[]>`
SELECT "phoneNumber"
FROM "CalAiPhoneNumber"
WHERE "outboundAgentId" = ${agent.id}
@@ -539,27 +551,61 @@ export class PrismaAgentRepository {
};
}
static async delete({ id }: { id: string }) {
return await prisma.agent.delete({
async delete({ id }: { id: string }) {
return await this.prismaClient.agent.delete({
where: { id },
});
}
static async linkToWorkflowStep({ workflowStepId, agentId }: { workflowStepId: number; agentId: string }) {
return await prisma.workflowStep.update({
async linkOutboundAgentToWorkflow({
workflowStepId,
agentId,
}: {
workflowStepId: number;
agentId: string;
}) {
return await this.prismaClient.workflowStep.update({
where: { id: workflowStepId },
data: { agentId },
});
}
static async canManageTeamResources({
async linkInboundAgentToWorkflow({
workflowStepId,
agentId,
}: {
workflowStepId: number;
agentId: string;
}) {
return await this.prismaClient.workflowStep.update({
where: {
id: workflowStepId,
},
data: {
inboundAgentId: agentId,
},
});
}
async updateEventTypeId({ agentId, eventTypeId }: { agentId: string; eventTypeId: number }) {
return await this.prismaClient.agent.update({
where: {
id: agentId,
},
data: {
inboundEventTypeId: eventTypeId,
},
});
}
async canManageTeamResources({
userId,
teamId,
}: {
userId: number;
teamId: number;
}): Promise<boolean> {
const result = await prisma.$queryRaw<{ count: bigint }[]>`
const result = await this.prismaClient.$queryRaw<{ count: bigint }[]>`
SELECT COUNT(*) as count
FROM "Membership"
WHERE "userId" = ${userId}
@@ -1,4 +1,4 @@
import { prisma } from "@calcom/prisma";
import { PrismaClient } from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums";
@@ -24,8 +24,10 @@ export interface AgentRawResult {
}
export class PrismaPhoneNumberRepository {
private static async getUserAccessibleTeamIds(userId: number): Promise<number[]> {
const memberships = await prisma.membership.findMany({
constructor(private prismaClient: PrismaClient) {}
private async getUserAccessibleTeamIds(userId: number): Promise<number[]> {
const memberships = await this.prismaClient.membership.findMany({
where: {
userId,
accepted: true,
@@ -38,8 +40,8 @@ export class PrismaPhoneNumberRepository {
return memberships.map((membership) => membership.teamId);
}
static async findByPhoneNumberAndUserId({ phoneNumber, userId }: { phoneNumber: string; userId: number }) {
return await prisma.calAiPhoneNumber.findFirstOrThrow({
async findByPhoneNumberAndUserId({ phoneNumber, userId }: { phoneNumber: string; userId: number }) {
return await this.prismaClient.calAiPhoneNumber.findFirstOrThrow({
where: {
phoneNumber,
userId,
@@ -61,8 +63,8 @@ export class PrismaPhoneNumberRepository {
});
}
static async findPhoneNumbersFromUserId({ userId }: { userId: number }) {
const phoneNumbers = await prisma.$queryRaw<_PhoneNumberRawResult[]>`
async findPhoneNumbersFromUserId({ userId }: { userId: number }) {
const phoneNumbers = await this.prismaClient.$queryRaw<_PhoneNumberRawResult[]>`
SELECT
pn.id,
pn."phoneNumber",
@@ -84,7 +86,7 @@ export class PrismaPhoneNumberRepository {
const phoneNumberIds = phoneNumbers.map((pn) => pn.id);
const agents =
phoneNumberIds.length > 0
? await prisma.$queryRaw<(AgentRawResult & { phoneNumberId: number; agentType: string })[]>`
? await this.prismaClient.$queryRaw<(AgentRawResult & { phoneNumberId: number; agentType: string })[]>`
SELECT
a.id,
a.name,
@@ -137,7 +139,7 @@ export class PrismaPhoneNumberRepository {
}));
}
static async createPhoneNumber({
async createPhoneNumber({
phoneNumber,
provider,
userId,
@@ -156,7 +158,7 @@ export class PrismaPhoneNumberRepository {
subscriptionStatus?: PhoneNumberSubscriptionStatus;
providerPhoneNumberId?: string;
}) {
return await prisma.calAiPhoneNumber.create({
return await this.prismaClient.calAiPhoneNumber.create({
select: {
id: true,
phoneNumber: true,
@@ -185,16 +187,16 @@ export class PrismaPhoneNumberRepository {
});
}
static async deletePhoneNumber({ phoneNumber }: { phoneNumber: string }) {
return await prisma.calAiPhoneNumber.delete({
async deletePhoneNumber({ phoneNumber }: { phoneNumber: string }) {
return await this.prismaClient.calAiPhoneNumber.delete({
where: {
phoneNumber,
},
});
}
static async findByStripeSubscriptionId({ stripeSubscriptionId }: { stripeSubscriptionId: string }) {
return await prisma.calAiPhoneNumber.findFirst({
async findByStripeSubscriptionId({ stripeSubscriptionId }: { stripeSubscriptionId: string }) {
return await this.prismaClient.calAiPhoneNumber.findFirst({
where: {
stripeSubscriptionId,
},
@@ -211,8 +213,8 @@ export class PrismaPhoneNumberRepository {
});
}
static async findByIdAndUserId({ id, userId }: { id: number; userId: number }) {
return await prisma.calAiPhoneNumber.findFirst({
async findByIdAndUserId({ id, userId }: { id: number; userId: number }) {
return await this.prismaClient.calAiPhoneNumber.findFirst({
where: {
id,
userId,
@@ -234,7 +236,7 @@ export class PrismaPhoneNumberRepository {
});
}
static async findByIdWithTeamAccess({
async findByIdWithTeamAccess({
id,
teamId,
userId,
@@ -249,7 +251,7 @@ export class PrismaPhoneNumberRepository {
return null;
}
return await prisma.calAiPhoneNumber.findFirst({
return await this.prismaClient.calAiPhoneNumber.findFirst({
where: {
id,
teamId,
@@ -271,7 +273,7 @@ export class PrismaPhoneNumberRepository {
});
}
static async findByPhoneNumberAndTeamId({
async findByPhoneNumberAndTeamId({
phoneNumber,
teamId,
userId,
@@ -286,7 +288,7 @@ export class PrismaPhoneNumberRepository {
return null;
}
return await prisma.calAiPhoneNumber.findFirst({
return await this.prismaClient.calAiPhoneNumber.findFirst({
where: {
phoneNumber,
teamId,
@@ -308,7 +310,7 @@ export class PrismaPhoneNumberRepository {
});
}
static async findManyWithUserAccess({
async findManyWithUserAccess({
userId,
teamId,
scope = "all",
@@ -373,12 +375,12 @@ export class PrismaPhoneNumberRepository {
ORDER BY pn."createdAt" DESC
`;
const phoneNumbers = await prisma.$queryRaw<_PhoneNumberRawResult[]>(query);
const phoneNumbers = await this.prismaClient.$queryRaw<_PhoneNumberRawResult[]>(query);
const phoneNumberIds = phoneNumbers.map((pn) => pn.id);
const agents =
phoneNumberIds.length > 0
? await prisma.$queryRaw<(AgentRawResult & { phoneNumberId: number; agentType: string })[]>`
? await this.prismaClient.$queryRaw<(AgentRawResult & { phoneNumberId: number; agentType: string })[]>`
SELECT
a.id,
a.name,
@@ -431,26 +433,29 @@ export class PrismaPhoneNumberRepository {
}));
}
static async updateSubscriptionStatus({
async updateSubscriptionStatus({
id,
subscriptionStatus,
disconnectOutboundAgent = false,
disconnectAgents = false,
}: {
id: number;
subscriptionStatus: PhoneNumberSubscriptionStatus;
disconnectOutboundAgent?: boolean;
disconnectAgents?: boolean;
}) {
const updateData: Prisma.CalAiPhoneNumberUpdateInput = {
subscriptionStatus,
};
if (disconnectOutboundAgent) {
if (disconnectAgents) {
updateData.outboundAgent = {
disconnect: true,
};
updateData.inboundAgent = {
disconnect: true,
};
}
return await prisma.calAiPhoneNumber.update({
return await this.prismaClient.calAiPhoneNumber.update({
where: {
id,
},
@@ -458,7 +463,7 @@ export class PrismaPhoneNumberRepository {
});
}
static async updateAgents({
async updateAgents({
id,
inboundProviderAgentId,
outboundProviderAgentId,
@@ -471,7 +476,7 @@ export class PrismaPhoneNumberRepository {
if (inboundProviderAgentId !== undefined) {
if (inboundProviderAgentId) {
const agent = await prisma.agent.findFirst({
const agent = await this.prismaClient.agent.findFirst({
where: {
providerAgentId: inboundProviderAgentId,
},
@@ -491,7 +496,7 @@ export class PrismaPhoneNumberRepository {
if (outboundProviderAgentId !== undefined) {
if (outboundProviderAgentId) {
const agent = await prisma.agent.findFirst({
const agent = await this.prismaClient.agent.findFirst({
where: {
providerAgentId: outboundProviderAgentId,
},
@@ -509,7 +514,7 @@ export class PrismaPhoneNumberRepository {
}
}
return await prisma.calAiPhoneNumber.update({
return await this.prismaClient.calAiPhoneNumber.update({
where: {
id,
},
@@ -517,8 +522,32 @@ export class PrismaPhoneNumberRepository {
});
}
static async findByPhoneNumber({ phoneNumber }: { phoneNumber: string }) {
return await prisma.calAiPhoneNumber.findFirst({
async updateInboundAgentId({
id,
agentId,
}: {
id: number;
agentId: string;
}) {
// Atomic update: only set if inboundAgentId is currently null
return await this.prismaClient.calAiPhoneNumber.updateMany({
where: {
id,
inboundAgentId: null,
},
data: { inboundAgentId: agentId },
});
}
async findInboundAgentIdByPhoneNumberId({ phoneNumberId }: { phoneNumberId: number }) {
return await this.prismaClient.calAiPhoneNumber.findUnique({
where: { id: phoneNumberId },
select: { inboundAgentId: true },
});
}
async findByPhoneNumber({ phoneNumber }: { phoneNumber: string }) {
return await this.prismaClient.calAiPhoneNumber.findFirst({
where: {
phoneNumber,
},
@@ -0,0 +1,20 @@
/*
Warnings:
- A unique constraint covering the columns `[inboundAgentId]` on the table `WorkflowStep` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "Agent" ADD COLUMN "inboundEventTypeId" INTEGER;
-- AlterTable
ALTER TABLE "WorkflowStep" ADD COLUMN "inboundAgentId" TEXT;
-- CreateIndex
CREATE INDEX "Agent_inboundEventTypeId_idx" ON "Agent"("inboundEventTypeId");
-- CreateIndex
CREATE UNIQUE INDEX "WorkflowStep_inboundAgentId_key" ON "WorkflowStep"("inboundAgentId");
-- AddForeignKey
ALTER TABLE "WorkflowStep" ADD CONSTRAINT "WorkflowStep_inboundAgentId_fkey" FOREIGN KEY ("inboundAgentId") REFERENCES "Agent"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+10
View File
@@ -247,6 +247,7 @@ model EventType {
createdAt DateTime? @default(now())
updatedAt DateTime? @updatedAt
@@unique([userId, slug])
@@unique([teamId, slug])
@@unique([userId, parentId])
@@ -1426,6 +1427,9 @@ model WorkflowStep {
agentId String? @unique
agent Agent? @relation(fields: [agentId], references: [id], onDelete: SetNull)
inboundAgentId String? @unique
inboundAgent Agent? @relation("WorkflowStepInboundAgent", fields: [inboundAgentId], references: [id], onDelete: SetNull)
@@index([workflowId])
}
@@ -2538,6 +2542,7 @@ enum PhoneNumberSubscriptionStatus {
UNPAID
}
// Cal AI Voice Agent
model Agent {
id String @id @default(uuid())
@@ -2552,6 +2557,9 @@ model Agent {
// Provider-specific agent ID (e.g., Retell AI agent ID)
providerAgentId String @unique
// Event type association for inbound agents
inboundEventTypeId Int?
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -2559,9 +2567,11 @@ model Agent {
inboundPhoneNumbers CalAiPhoneNumber[] @relation("InboundAgent")
outboundPhoneNumbers CalAiPhoneNumber[] @relation("OutboundAgent")
workflowStep WorkflowStep?
inboundWorkflowStep WorkflowStep? @relation("WorkflowStepInboundAgent")
@@index([userId])
@@index([teamId])
@@index([inboundEventTypeId])
}
model CalAiPhoneNumber {
@@ -7,8 +7,10 @@ import { ZDeleteInputSchema } from "./delete.schema";
import { ZGetInputSchema } from "./get.schema";
import { ZListInputSchema } from "./list.schema";
import { ZListCallsInputSchema } from "./listCalls.schema";
import { ZSetupInboundAgentInputSchema } from "./setupInboundAgent.schema";
import { ZTestCallInputSchema } from "./testCall.schema";
import { ZUpdateInputSchema } from "./update.schema";
import { ZUpdateInboundAgentEventTypeInputSchema } from "./updateInboundAgentEventType.schema";
export const aiVoiceAgentRouter = router({
list: authedProcedure.input(ZListInputSchema).query(async ({ ctx, input }) => {
@@ -72,7 +74,7 @@ export const aiVoiceAgentRouter = router({
ctx,
input,
});
}),
}),
createWebCall: eventOwnerProcedure.input(ZCreateWebCallInputSchema).mutation(async ({ ctx, input }) => {
const { createWebCallHandler } = await import("./createWebCall.handler");
@@ -90,4 +92,24 @@ export const aiVoiceAgentRouter = router({
ctx,
});
}),
setupInboundAgent: authedProcedure.input(ZSetupInboundAgentInputSchema).mutation(async ({ ctx, input }) => {
const { setupInboundAgentHandler } = await import("./setupInboundAgent.handler");
return setupInboundAgentHandler({
ctx,
input,
});
}),
updateInboundAgentEventType: eventOwnerProcedure
.input(ZUpdateInboundAgentEventTypeInputSchema)
.mutation(async ({ ctx, input }) => {
const { updateInboundAgentEventTypeHandler } = await import("./updateInboundAgentEventType.handler");
return updateInboundAgentEventTypeHandler({
ctx,
input,
});
}),
});
@@ -22,7 +22,7 @@ export const createHandler = async ({ ctx, input }: CreateHandlerOptions) => {
?.generalPrompt
: undefined;
return await aiService.createAgent({
return await aiService.createOutboundAgent({
name,
userId: ctx.user.id,
teamId,
@@ -0,0 +1,53 @@
import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../types";
import type { TSetupInboundAgentInputSchema } from "./setupInboundAgent.schema";
type SetupInboundAgentHandlerOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TSetupInboundAgentInputSchema;
};
export const setupInboundAgentHandler = async ({ ctx, input }: SetupInboundAgentHandlerOptions) => {
const log = logger.getSubLogger({ prefix: ["setupInboundAgentHandler"] });
try {
const { phoneNumber, teamId, workflowStepId } = input;
const userId = ctx.user.id;
const userTimeZone = ctx.user.timeZone || "UTC";
const aiService = createDefaultAIPhoneServiceProvider();
const createAgentResult = await aiService.createInboundAgent({
name: `Inbound Agent - ${phoneNumber}`,
phoneNumber,
userId,
teamId,
workflowStepId,
userTimeZone,
});
return {
success: true,
agentId: createAgentResult.id,
message: "Inbound agent configured successfully",
};
} catch (error) {
log.error("Failed to setup inbound agent", { error });
if (error instanceof HttpError) {
throw error;
}
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to setup inbound agent",
});
}
};
@@ -0,0 +1,9 @@
import { z } from "zod";
export const ZSetupInboundAgentInputSchema = z.object({
phoneNumber: z.string(),
teamId: z.number().optional(),
workflowStepId: z.number(),
});
export type TSetupInboundAgentInputSchema = z.infer<typeof ZSetupInboundAgentInputSchema>;
@@ -2,7 +2,7 @@ import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import logger from "@calcom/lib/logger";
import { PrismaAgentRepository } from "@calcom/lib/server/repository/PrismaAgentRepository";
import { prisma } from "@calcom/prisma";
import prisma from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
@@ -27,7 +27,8 @@ export const testCallHandler = async ({ ctx, input }: TestCallHandlerOptions) =>
return;
}
const agent = await PrismaAgentRepository.findByIdWithCallAccess({
const agentRepo = new PrismaAgentRepository(prisma);
const agent = await agentRepo.findByIdWithCallAccess({
id: input.agentId,
userId: ctx.user.id,
});
@@ -0,0 +1,71 @@
import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone";
import { replaceEventTypePlaceholders } from "@calcom/features/ee/workflows/components/agent-configuration/utils/promptUtils";
import logger from "@calcom/lib/logger";
import { PrismaAgentRepository } from "@calcom/lib/server/repository/PrismaAgentRepository";
import prisma from "@calcom/prisma";
import type { TrpcSessionUser } from "../../../types";
import type { TUpdateInboundAgentEventTypeInputSchema } from "./updateInboundAgentEventType.schema";
type UpdateInboundAgentEventTypeHandlerOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TUpdateInboundAgentEventTypeInputSchema;
};
export const updateInboundAgentEventTypeHandler = async ({
ctx,
input,
}: UpdateInboundAgentEventTypeHandlerOptions) => {
const log = logger.getSubLogger({ prefix: ["updateInboundAgentEventTypeHandler"] });
try {
const { agentId, eventTypeId, teamId } = input;
const userId = ctx.user.id;
const userTimeZone = ctx.user.timeZone || "UTC";
const aiService = createDefaultAIPhoneServiceProvider();
const agentDetails = await aiService.getAgentWithDetails({
id: agentId,
userId,
teamId,
});
if (!agentDetails.retellData.generalPrompt) {
throw new Error("Agent configuration not found");
}
// Replace placeholders in the prompt with the specific event type ID
const updatedPrompt = replaceEventTypePlaceholders(agentDetails.retellData.generalPrompt, eventTypeId);
// Update tools and prompt using existing updateAgentConfiguration method
await aiService.updateToolsFromAgentId(agentDetails.retellData.agentId, {
eventTypeId,
timeZone: userTimeZone,
userId,
teamId,
});
const result = await aiService.updateAgentConfiguration({
id: agentId,
userId,
teamId,
generalPrompt: updatedPrompt,
});
const agentRepo = new PrismaAgentRepository(prisma);
await agentRepo.updateEventTypeId({
agentId,
eventTypeId,
});
return {
success: true,
message: result.message,
};
} catch (error) {
log.error("Failed to update inbound agent prompt", { error });
throw error;
}
};
@@ -0,0 +1,9 @@
import { z } from "zod";
export const ZUpdateInboundAgentEventTypeInputSchema = z.object({
agentId: z.string(),
eventTypeId: z.number(),
teamId: z.number().optional(),
});
export type TUpdateInboundAgentEventTypeInputSchema = z.infer<typeof ZUpdateInboundAgentEventTypeInputSchema>;
@@ -1,4 +1,5 @@
import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository";
import prisma from "@calcom/prisma";
import type { TrpcSessionUser } from "../../../types";
import type { TListInputSchema } from "./list.schema";
@@ -11,7 +12,8 @@ type ListHandlerOptions = {
};
export const listHandler = async ({ ctx, input }: ListHandlerOptions) => {
return await PrismaPhoneNumberRepository.findManyWithUserAccess({
const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
return await phoneNumberRepo.findManyWithUserAccess({
userId: ctx.user.id,
teamId: input?.teamId,
scope: input?.scope || "all",
@@ -3,6 +3,7 @@ import { WorkflowRepository } from "@calcom/lib/server/repository/workflow";
import { prisma } from "@calcom/prisma";
import { WorkflowActions } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import logger from "@calcom/lib/logger";
import { TRPCError } from "@trpc/server";
@@ -22,6 +23,7 @@ type DeleteOptions = {
export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
const { id } = input;
const log = logger.getSubLogger({ prefix: ["workflows/deleteHandler"] });
const workflowToDelete = await prisma.workflow.findUnique({
where: {
@@ -56,6 +58,11 @@ export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
},
},
},
inboundAgent: {
select: {
id: true,
},
},
},
},
team: {
@@ -100,7 +107,7 @@ export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
});
}
} catch (error) {
console.error(`Failed to handle phone number ${phoneNumber.phoneNumber}:`, error);
log.error(`Failed to handle phone number ${phoneNumber.phoneNumber}:`, error);
}
}
}
@@ -113,8 +120,19 @@ export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
teamId: workflowToDelete.teamId ?? undefined,
});
} catch (error) {
console.error(`Failed to delete agent ${step.agent.id}:`, error);
// Continue with deletion even if agent deletion fails
log.error(`Failed to delete agent ${step.agent.id}:`, error);
}
}
if (step.inboundAgent) {
try {
await aiPhoneService.deleteAgent({
id: step.inboundAgent.id,
userId: ctx.user.id,
teamId: workflowToDelete.teamId ?? undefined,
});
} catch (error) {
log.error(`Failed to delete inbound agent ${step.inboundAgent.id}:`, error);
}
}
}
@@ -440,6 +440,22 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
log.error(message);
}
if (oldStep.inboundAgentId) {
try {
await aiPhoneService.deleteAgent({
id: oldStep.inboundAgentId,
userId: user.id,
teamId: userWorkflow.teamId ?? undefined,
});
} catch (error) {
const message = `Failed to delete inbound agent ${
oldStep.inboundAgentId
} from external service: ${error instanceof Error ? error.message : "Unknown error"}`;
externalErrors.push(message);
log.error(message);
}
}
// If there were external errors, we should log them for manual cleanup
// but the operation is considered successful since DB is consistent
if (externalErrors.length > 0) {
@@ -459,6 +475,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
...newStep,
verifiedAt: oldStep.verifiedAt,
agentId: newStep.agentId || null,
inboundAgentId: newStep.inboundAgentId || null,
})
) {
// check if step that require team plan already existed before
@@ -26,6 +26,7 @@ export const ZUpdateInputSchema = z.object({
senderName: z.string().nullable(),
includeCalendarEvent: z.boolean(),
agentId: z.string().nullish(),
inboundAgentId: z.string().nullish(),
})
.array(),
trigger: z.enum(WORKFLOW_TRIGGER_EVENTS),
@@ -101,6 +101,7 @@ export const lucideIconList = new Set([
"pause",
"pencil",
"phone-call",
"phone-incoming",
"phone-outgoing",
"phone",
"play",
@@ -108,6 +108,7 @@ export type IconName =
| "pause"
| "pencil"
| "phone-call"
| "phone-incoming"
| "phone-off"
| "phone-outgoing"
| "phone"