From aa48e72c120843ead2e30f32c6862821bb47313b Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Thu, 9 Oct 2025 16:10:37 +0530 Subject: [PATCH] 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> --- .../retell-ai/__tests__/route.test.ts | 60 +- apps/web/app/api/webhooks/retell-ai/route.ts | 14 +- apps/web/public/icons/sprite.svg | 5 + apps/web/public/static/locales/en/common.json | 17 + .../calAIPhone/AIPhoneServiceRegistry.test.ts | 6 +- .../interfaces/AIPhoneService.interface.ts | 18 +- .../features/calAIPhone/promptTemplates.ts | 2 +- .../adapters/PrismaAgentRepositoryAdapter.ts | 39 +- .../PrismaPhoneNumberRepositoryAdapter.ts | 39 +- .../interfaces/AgentRepositoryInterface.ts | 8 +- .../PhoneNumberRepositoryInterface.ts | 12 +- .../RetellAIPhoneServiceProvider.test.ts | 6 +- .../retellAI/RetellAIPhoneServiceProvider.ts | 24 +- ...etellAIPhoneServiceProviderFactory.test.ts | 4 +- .../retellAI/RetellAIService.test.ts | 45 +- .../providers/retellAI/RetellAIService.ts | 46 +- .../retellAI/RetellAIServiceMapper.ts | 3 +- .../retellAI/RetellSDKClient.test.ts | 4 +- .../providers/retellAI/RetellSDKClient.ts | 2 +- .../services/AIConfigurationService.ts | 26 +- .../retellAI/services/AgentService.ts | 172 ++- .../retellAI/services/BillingService.ts | 23 +- .../retellAI/services/CallService.ts | 20 +- .../retellAI/services/PhoneNumberService.ts | 58 +- .../providers/retellAI/services/README.md | 4 +- .../retellAI/services/VoiceService.ts | 15 +- .../__tests__/AIConfigurationService.test.ts | 8 +- .../services/__tests__/BillingService.test.ts | 17 +- .../services/__tests__/CallService.test.ts | 5 +- .../__tests__/PhoneNumberService.test.ts | 12 +- .../retellAI/services/__tests__/test-utils.ts | 4 +- .../calAIPhone/providers/retellAI/types.ts | 7 +- .../webhook/_checkout.session.completed.ts | 10 +- .../webhook/_customer.subscription.deleted.ts | 6 +- .../webhook/_customer.subscription.updated.ts | 5 +- .../components/AgentConfigurationSheet.tsx | 1293 ----------------- .../components/VoiceSelectionDialog.tsx | 77 +- .../ee/workflows/components/WebCallDialog.tsx | 3 +- .../components/WorkflowDetailsPage.tsx | 20 +- .../components/WorkflowStepContainer.tsx | 38 +- .../AgentConfigurationSheet.tsx | 374 +++++ .../components/dialogs/BuyNumberDialog.tsx | 134 ++ .../dialogs/ConfirmationDialogs.tsx | 93 ++ .../components/dialogs/ImportNumberDialog.tsx | 252 ++++ .../components/forms/AgentConfigForm.tsx | 176 +++ .../components/forms/LanguageSelector.tsx | 37 + .../components/forms/VoiceSelector.tsx | 30 + .../components/tabs/IncomingCallsTab.tsx | 190 +++ .../components/tabs/OutgoingCallsTab.tsx | 35 + .../components/tabs/PhoneNumberTab.tsx | 350 +++++ .../hooks/useAgentForms.ts | 74 + .../hooks/usePhoneNumberActions.ts | 40 + .../agent-configuration/types/schemas.ts | 45 + .../agent-configuration/utils/constants.ts | 38 + .../agent-configuration/utils/promptUtils.ts | 40 + .../ee/workflows/hooks/useAgentsData.ts | 107 ++ .../features/ee/workflows/pages/workflow.tsx | 6 +- .../repository/PrismaAgentRepository.ts | 114 +- .../repository/PrismaPhoneNumberRepository.ts | 97 +- .../migration.sql | 20 + packages/prisma/schema.prisma | 10 + .../routers/viewer/aiVoiceAgent/_router.ts | 24 +- .../viewer/aiVoiceAgent/create.handler.ts | 2 +- .../aiVoiceAgent/setupInboundAgent.handler.ts | 53 + .../aiVoiceAgent/setupInboundAgent.schema.ts | 9 + .../viewer/aiVoiceAgent/testCall.handler.ts | 5 +- .../updateInboundAgentEventType.handler.ts | 71 + .../updateInboundAgentEventType.schema.ts | 9 + .../viewer/phoneNumber/list.handler.ts | 4 +- .../viewer/workflows/delete.handler.ts | 24 +- .../viewer/workflows/update.handler.ts | 17 + .../routers/viewer/workflows/update.schema.ts | 1 + packages/ui/components/icon/icon-list.mjs | 1 + packages/ui/components/icon/icon-names.ts | 1 + 74 files changed, 2980 insertions(+), 1680 deletions(-) delete mode 100644 packages/features/ee/workflows/components/AgentConfigurationSheet.tsx create mode 100644 packages/features/ee/workflows/components/agent-configuration/AgentConfigurationSheet.tsx create mode 100644 packages/features/ee/workflows/components/agent-configuration/components/dialogs/BuyNumberDialog.tsx create mode 100644 packages/features/ee/workflows/components/agent-configuration/components/dialogs/ConfirmationDialogs.tsx create mode 100644 packages/features/ee/workflows/components/agent-configuration/components/dialogs/ImportNumberDialog.tsx create mode 100644 packages/features/ee/workflows/components/agent-configuration/components/forms/AgentConfigForm.tsx create mode 100644 packages/features/ee/workflows/components/agent-configuration/components/forms/LanguageSelector.tsx create mode 100644 packages/features/ee/workflows/components/agent-configuration/components/forms/VoiceSelector.tsx create mode 100644 packages/features/ee/workflows/components/agent-configuration/components/tabs/IncomingCallsTab.tsx create mode 100644 packages/features/ee/workflows/components/agent-configuration/components/tabs/OutgoingCallsTab.tsx create mode 100644 packages/features/ee/workflows/components/agent-configuration/components/tabs/PhoneNumberTab.tsx create mode 100644 packages/features/ee/workflows/components/agent-configuration/hooks/useAgentForms.ts create mode 100644 packages/features/ee/workflows/components/agent-configuration/hooks/usePhoneNumberActions.ts create mode 100644 packages/features/ee/workflows/components/agent-configuration/types/schemas.ts create mode 100644 packages/features/ee/workflows/components/agent-configuration/utils/constants.ts create mode 100644 packages/features/ee/workflows/components/agent-configuration/utils/promptUtils.ts create mode 100644 packages/features/ee/workflows/hooks/useAgentsData.ts create mode 100644 packages/prisma/migrations/20251006103705_add_inbound_agent/migration.sql create mode 100644 packages/trpc/server/routers/viewer/aiVoiceAgent/setupInboundAgent.handler.ts create mode 100644 packages/trpc/server/routers/viewer/aiVoiceAgent/setupInboundAgent.schema.ts create mode 100644 packages/trpc/server/routers/viewer/aiVoiceAgent/updateInboundAgentEventType.handler.ts create mode 100644 packages/trpc/server/routers/viewer/aiVoiceAgent/updateInboundAgentEventType.schema.ts diff --git a/apps/web/app/api/webhooks/retell-ai/__tests__/route.test.ts b/apps/web/app/api/webhooks/retell-ai/__tests__/route.test.ts index 68d32740a2..7d3d6d922a 100644 --- a/apps/web/app/api/webhooks/retell-ai/__tests__/route.test.ts +++ b/apps/web/app/api/webhooks/retell-ai/__tests__/route.test.ts @@ -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(); diff --git a/apps/web/app/api/webhooks/retell-ai/route.ts b/apps/web/app/api/webhooks/retell-ai/route.ts index 3cb4572a72..bca92b4ece 100644 --- a/apps/web/app/api/webhooks/retell-ai/route.ts +++ b/apps/web/app/api/webhooks/retell-ai/route.ts @@ -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, }); diff --git a/apps/web/public/icons/sprite.svg b/apps/web/public/icons/sprite.svg index 182b16ee59..bda988eb28 100644 --- a/apps/web/public/icons/sprite.svg +++ b/apps/web/public/icons/sprite.svg @@ -525,6 +525,11 @@ + + + + + diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index e10c43eff9..2d0cc31075 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/features/calAIPhone/AIPhoneServiceRegistry.test.ts b/packages/features/calAIPhone/AIPhoneServiceRegistry.test.ts index 6dcfb74f68..d94fd47374 100644 --- a/packages/features/calAIPhone/AIPhoneServiceRegistry.test.ts +++ b/packages/features/calAIPhone/AIPhoneServiceRegistry.test.ts @@ -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(), diff --git a/packages/features/calAIPhone/interfaces/AIPhoneService.interface.ts b/packages/features/calAIPhone/interfaces/AIPhoneService.interface.ts index 8371517361..5be00ba24a 100644 --- a/packages/features/calAIPhone/interfaces/AIPhoneService.interface.ts +++ b/packages/features/calAIPhone/interfaces/AIPhoneService.interface.ts @@ -285,7 +285,7 @@ export interface AIPhoneServiceProvider; + /** + * 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 */ diff --git a/packages/features/calAIPhone/promptTemplates.ts b/packages/features/calAIPhone/promptTemplates.ts index 37394b811d..19126e9348 100644 --- a/packages/features/calAIPhone/promptTemplates.ts +++ b/packages/features/calAIPhone/promptTemplates.ts @@ -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. diff --git a/packages/features/calAIPhone/providers/adapters/PrismaAgentRepositoryAdapter.ts b/packages/features/calAIPhone/providers/adapters/PrismaAgentRepositoryAdapter.ts index 7022fafed5..ec3f958e64 100644 --- a/packages/features/calAIPhone/providers/adapters/PrismaAgentRepositoryAdapter.ts +++ b/packages/features/calAIPhone/providers/adapters/PrismaAgentRepositoryAdapter.ts @@ -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 { - return await PrismaAgentRepository.canManageTeamResources(params); + const agentRepo = new PrismaAgentRepository(prisma); + return await agentRepo.canManageTeamResources(params); } async findByIdWithUserAccess(params: { agentId: string; userId: number }): Promise { - return await PrismaAgentRepository.findByIdWithUserAccess(params); + const agentRepo = new PrismaAgentRepository(prisma); + return await agentRepo.findByIdWithUserAccess(params); } async findByProviderAgentIdWithUserAccess(params: { providerAgentId: string; userId: number; }): Promise { - 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 { - 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 { - 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 { - 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 { - return await PrismaAgentRepository.findByIdWithAdminAccess(params); + const agentRepo = new PrismaAgentRepository(prisma); + return await agentRepo.findByIdWithAdminAccess(params); } async findByIdWithCallAccess(params: { id: string; userId: number; }): Promise { - return await PrismaAgentRepository.findByIdWithCallAccess(params); + const agentRepo = new PrismaAgentRepository(prisma); + return await agentRepo.findByIdWithCallAccess(params); } async delete(params: { id: string }): Promise { - await PrismaAgentRepository.delete(params); + const agentRepo = new PrismaAgentRepository(prisma); + await agentRepo.delete(params); } - async linkToWorkflowStep(params: { workflowStepId: number; agentId: string }): Promise { - await PrismaAgentRepository.linkToWorkflowStep(params); + async linkOutboundAgentToWorkflow(params: { workflowStepId: number; agentId: string }): Promise { + const agentRepo = new PrismaAgentRepository(prisma); + await agentRepo.linkOutboundAgentToWorkflow(params); + } + + async linkInboundAgentToWorkflow(params: { workflowStepId: number; agentId: string }): Promise { + const agentRepo = new PrismaAgentRepository(prisma); + await agentRepo.linkInboundAgentToWorkflow(params); } } diff --git a/packages/features/calAIPhone/providers/adapters/PrismaPhoneNumberRepositoryAdapter.ts b/packages/features/calAIPhone/providers/adapters/PrismaPhoneNumberRepositoryAdapter.ts index 2aa5cd97a8..be8630b35c 100644 --- a/packages/features/calAIPhone/providers/adapters/PrismaPhoneNumberRepositoryAdapter.ts +++ b/packages/features/calAIPhone/providers/adapters/PrismaPhoneNumberRepositoryAdapter.ts @@ -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 { - 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 { - return await PrismaPhoneNumberRepository.findByPhoneNumberAndTeamId(params); + const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma); + return await phoneNumberRepo.findByPhoneNumberAndTeamId(params); } async findByIdAndUserId(params: { id: number; userId: number }): Promise { - 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 { - 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 { + 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 { - 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 { - 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 { - 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); + } } diff --git a/packages/features/calAIPhone/providers/interfaces/AgentRepositoryInterface.ts b/packages/features/calAIPhone/providers/interfaces/AgentRepositoryInterface.ts index e47d86a1ea..5452c314ad 100644 --- a/packages/features/calAIPhone/providers/interfaces/AgentRepositoryInterface.ts +++ b/packages/features/calAIPhone/providers/interfaces/AgentRepositoryInterface.ts @@ -68,7 +68,12 @@ export interface AgentRepositoryInterface { /** * Link agent to workflow step */ - linkToWorkflowStep(params: { workflowStepId: number; agentId: string }): Promise; + linkOutboundAgentToWorkflow(params: { workflowStepId: number; agentId: string }): Promise; + + /** + * Link inbound agent to workflow + */ + linkInboundAgentToWorkflow(params: { workflowStepId: number; agentId: string }): Promise; } /** @@ -81,6 +86,7 @@ export interface AgentData { enabled: boolean; userId: number | null; teamId: number | null; + inboundEventTypeId?: number | null; createdAt: Date; updatedAt: Date; } diff --git a/packages/features/calAIPhone/providers/interfaces/PhoneNumberRepositoryInterface.ts b/packages/features/calAIPhone/providers/interfaces/PhoneNumberRepositoryInterface.ts index d4eac3fdd8..0aef238b49 100644 --- a/packages/features/calAIPhone/providers/interfaces/PhoneNumberRepositoryInterface.ts +++ b/packages/features/calAIPhone/providers/interfaces/PhoneNumberRepositoryInterface.ts @@ -63,7 +63,7 @@ export interface PhoneNumberRepositoryInterface { updateSubscriptionStatus(params: { id: number; subscriptionStatus: PhoneNumberSubscriptionStatus; - disconnectOutboundAgent?: boolean; + disconnectAgents?: boolean; }): Promise; /** @@ -74,6 +74,16 @@ export interface PhoneNumberRepositoryInterface { inboundProviderAgentId?: string | null; outboundProviderAgentId?: string | null; }): Promise; + + /** + * 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>; } /** diff --git a/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProvider.test.ts b/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProvider.test.ts index 4c789aea2d..03bf7e23a5 100644 --- a/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProvider.test.ts +++ b/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProvider.test.ts @@ -42,7 +42,7 @@ function createMockRetellAIService(overrides: Partial = {}): 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 diff --git a/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProvider.ts b/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProvider.ts index a4d513cae5..533ba682a6 100644 --- a/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProvider.ts +++ b/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProvider.ts @@ -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, diff --git a/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProviderFactory.test.ts b/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProviderFactory.test.ts index 9988a520e2..5eebe7081e 100644 --- a/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProviderFactory.test.ts +++ b/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProviderFactory.test.ts @@ -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(), })), })); diff --git a/packages/features/calAIPhone/providers/retellAI/RetellAIService.test.ts b/packages/features/calAIPhone/providers/retellAI/RetellAIService.test.ts index feab31445c..87c77bf719 100644 --- a/packages/features/calAIPhone/providers/retellAI/RetellAIService.test.ts +++ b/packages/features/calAIPhone/providers/retellAI/RetellAIService.test.ts @@ -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({ diff --git a/packages/features/calAIPhone/providers/retellAI/RetellAIService.ts b/packages/features/calAIPhone/providers/retellAI/RetellAIService.ts index c0e1952775..e75dd7e1aa 100644 --- a/packages/features/calAIPhone/providers/retellAI/RetellAIService.ts +++ b/packages/features/calAIPhone/providers/retellAI/RetellAIService.ts @@ -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; diff --git a/packages/features/calAIPhone/providers/retellAI/RetellAIServiceMapper.ts b/packages/features/calAIPhone/providers/retellAI/RetellAIServiceMapper.ts index d982786c78..4800dca139 100644 --- a/packages/features/calAIPhone/providers/retellAI/RetellAIServiceMapper.ts +++ b/packages/features/calAIPhone/providers/retellAI/RetellAIServiceMapper.ts @@ -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, diff --git a/packages/features/calAIPhone/providers/retellAI/RetellSDKClient.test.ts b/packages/features/calAIPhone/providers/retellAI/RetellSDKClient.test.ts index 6a7f399021..191cae01e5 100644 --- a/packages/features/calAIPhone/providers/retellAI/RetellSDKClient.test.ts +++ b/packages/features/calAIPhone/providers/retellAI/RetellSDKClient.test.ts @@ -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); diff --git a/packages/features/calAIPhone/providers/retellAI/RetellSDKClient.ts b/packages/features/calAIPhone/providers/retellAI/RetellSDKClient.ts index 3bc5eb7282..eed22b5b61 100644 --- a/packages/features/calAIPhone/providers/retellAI/RetellSDKClient.ts +++ b/packages/features/calAIPhone/providers/retellAI/RetellSDKClient.ts @@ -95,7 +95,7 @@ export class RetellSDKClient implements RetellAIRepository { } } - async createAgent(data: CreateAgentRequest): Promise { + async createOutboundAgent(data: CreateAgentRequest): Promise { this.logger.info("Creating agent via SDK", { agentName: data.agent_name, }); diff --git a/packages/features/calAIPhone/providers/retellAI/services/AIConfigurationService.ts b/packages/features/calAIPhone/providers/retellAI/services/AIConfigurationService.ts index 5405fef00e..4afcf8f3d5 100644 --- a/packages/features/calAIPhone/providers/retellAI/services/AIConfigurationService.ts +++ b/packages/features/calAIPhone/providers/retellAI/services/AIConfigurationService.ts @@ -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> { 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, diff --git a/packages/features/calAIPhone/providers/retellAI/services/AgentService.ts b/packages/features/calAIPhone/providers/retellAI/services/AgentService.ts index 6c867c9c56..caab41876e 100644 --- a/packages/features/calAIPhone/providers/retellAI/services/AgentService.ts +++ b/packages/features/calAIPhone/providers/retellAI/services/AgentService.ts @@ -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 ) => Promise>; }) { - 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; }) { - 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" }; } diff --git a/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts b/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts index 0cc70d29a5..f71b3fb775 100644 --- a/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts +++ b/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts @@ -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, diff --git a/packages/features/calAIPhone/providers/retellAI/services/CallService.ts b/packages/features/calAIPhone/providers/retellAI/services/CallService.ts index c0b4cebd2b..ce363adae4 100644 --- a/packages/features/calAIPhone/providers/retellAI/services/CallService.ts +++ b/packages/features/calAIPhone/providers/retellAI/services/CallService.ts @@ -16,14 +16,16 @@ interface RetellAIServiceInterface { ): Promise; } +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 }), diff --git a/packages/features/calAIPhone/providers/retellAI/services/PhoneNumberService.ts b/packages/features/calAIPhone/providers/retellAI/services/PhoneNumberService.ts index cf9f4e2741..ee815ba780 100644 --- a/packages/features/calAIPhone/providers/retellAI/services/PhoneNumberService.ts +++ b/packages/features/calAIPhone/providers/retellAI/services/PhoneNumberService.ts @@ -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 & { 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, }); diff --git a/packages/features/calAIPhone/providers/retellAI/services/README.md b/packages/features/calAIPhone/providers/retellAI/services/README.md index e09a5a20a0..69d74c9000 100644 --- a/packages/features/calAIPhone/providers/retellAI/services/README.md +++ b/packages/features/calAIPhone/providers/retellAI/services/README.md @@ -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', diff --git a/packages/features/calAIPhone/providers/retellAI/services/VoiceService.ts b/packages/features/calAIPhone/providers/retellAI/services/VoiceService.ts index d96aa71e15..5b38443db8 100644 --- a/packages/features/calAIPhone/providers/retellAI/services/VoiceService.ts +++ b/packages/features/calAIPhone/providers/retellAI/services/VoiceService.ts @@ -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 { 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 { }); } } -} \ No newline at end of file +} diff --git a/packages/features/calAIPhone/providers/retellAI/services/__tests__/AIConfigurationService.test.ts b/packages/features/calAIPhone/providers/retellAI/services/__tests__/AIConfigurationService.test.ts index 25d0bdca42..315851eea9 100644 --- a/packages/features/calAIPhone/providers/retellAI/services/__tests__/AIConfigurationService.test.ts +++ b/packages/features/calAIPhone/providers/retellAI/services/__tests__/AIConfigurationService.test.ts @@ -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 () => { diff --git a/packages/features/calAIPhone/providers/retellAI/services/__tests__/BillingService.test.ts b/packages/features/calAIPhone/providers/retellAI/services/__tests__/BillingService.test.ts index 4b7865f553..a6eb091ba3 100644 --- a/packages/features/calAIPhone/providers/retellAI/services/__tests__/BillingService.test.ts +++ b/packages/features/calAIPhone/providers/retellAI/services/__tests__/BillingService.test.ts @@ -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, diff --git a/packages/features/calAIPhone/providers/retellAI/services/__tests__/CallService.test.ts b/packages/features/calAIPhone/providers/retellAI/services/__tests__/CallService.test.ts index 8a78a0f5fa..b8ed0e70ea 100644 --- a/packages/features/calAIPhone/providers/retellAI/services/__tests__/CallService.test.ts +++ b/packages/features/calAIPhone/providers/retellAI/services/__tests__/CallService.test.ts @@ -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); }); diff --git a/packages/features/calAIPhone/providers/retellAI/services/__tests__/PhoneNumberService.test.ts b/packages/features/calAIPhone/providers/retellAI/services/__tests__/PhoneNumberService.test.ts index 9c5af76695..f75fd99901 100644 --- a/packages/features/calAIPhone/providers/retellAI/services/__tests__/PhoneNumberService.test.ts +++ b/packages/features/calAIPhone/providers/retellAI/services/__tests__/PhoneNumberService.test.ts @@ -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(() => { diff --git a/packages/features/calAIPhone/providers/retellAI/services/__tests__/test-utils.ts b/packages/features/calAIPhone/providers/retellAI/services/__tests__/test-utils.ts index 9cdc7262c5..cae376afda 100644 --- a/packages/features/calAIPhone/providers/retellAI/services/__tests__/test-utils.ts +++ b/packages/features/calAIPhone/providers/retellAI/services/__tests__/test-utils.ts @@ -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(), }; }; diff --git a/packages/features/calAIPhone/providers/retellAI/types.ts b/packages/features/calAIPhone/providers/retellAI/types.ts index 67ee01fac5..fe14777da8 100644 --- a/packages/features/calAIPhone/providers/retellAI/types.ts +++ b/packages/features/calAIPhone/providers/retellAI/types.ts @@ -103,7 +103,7 @@ export type CreateAgentRequest = Retell.AgentCreateParams; export type UpdateLLMRequest = Retell.LlmUpdateParams; export type UpdateAgentRequest = Retell.AgentUpdateParams; export type Agent = NonNullable< - Awaited> + Awaited> >; 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; // Agent operations - createAgent(data: CreateAgentRequest): Promise; + createOutboundAgent(data: CreateAgentRequest): Promise; getAgent(agentId: string): Promise; updateAgent(agentId: string, data: UpdateAgentRequest): Promise; deleteAgent(agentId: string): Promise; @@ -181,7 +182,7 @@ export interface RetellAIRepository { // Call operations createPhoneCall(data: CreatePhoneCallParams): Promise; - + listCalls(params: RetellCallListParams): Promise; createWebCall( diff --git a/packages/features/ee/billing/api/webhook/_checkout.session.completed.ts b/packages/features/ee/billing/api/webhook/_checkout.session.completed.ts index 418e8c96a9..32b9908858 100644 --- a/packages/features/ee/billing/api/webhook/_checkout.session.completed.ts +++ b/packages/features/ee/billing/api/webhook/_checkout.session.completed.ts @@ -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, }); diff --git a/packages/features/ee/billing/api/webhook/_customer.subscription.deleted.ts b/packages/features/ee/billing/api/webhook/_customer.subscription.deleted.ts index 25b2376b6c..100995bb59 100644 --- a/packages/features/ee/billing/api/webhook/_customer.subscription.deleted.ts +++ b/packages/features/ee/billing/api/webhook/_customer.subscription.deleted.ts @@ -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>> + phoneNumber: NonNullable>> ) { if (!subscription.id) { throw new HttpCode(400, "Subscription ID not found"); diff --git a/packages/features/ee/billing/api/webhook/_customer.subscription.updated.ts b/packages/features/ee/billing/api/webhook/_customer.subscription.updated.ts index 02e307db36..750669015a 100644 --- a/packages/features/ee/billing/api/webhook/_customer.subscription.updated.ts +++ b/packages/features/ee/billing/api/webhook/_customer.subscription.updated.ts @@ -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, }); diff --git a/packages/features/ee/workflows/components/AgentConfigurationSheet.tsx b/packages/features/ee/workflows/components/AgentConfigurationSheet.tsx deleted file mode 100644 index 41b35a4a4f..0000000000 --- a/packages/features/ee/workflows/components/AgentConfigurationSheet.tsx +++ /dev/null @@ -1,1293 +0,0 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { isValidPhoneNumber } from "libphonenumber-js"; -import { useState, useRef, useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; -import type { UseFormReturn } from "react-hook-form"; -import { z } from "zod"; - -import { Dialog } from "@calcom/features/components/controlled-dialog"; -import type { Language } from "@calcom/features/calAIPhone/providers/retellAI/types"; -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 { Alert } from "@calcom/ui/components/alert"; -import { Badge } from "@calcom/ui/components/badge"; -import { Button } from "@calcom/ui/components/button"; -import { DialogContent, DialogHeader, DialogFooter as BaseDialogFooter } from "@calcom/ui/components/dialog"; -import { ConfirmationDialogContent } from "@calcom/ui/components/dialog"; -import { Dialog as UIDialog } from "@calcom/ui/components/dialog"; -import { - Dropdown, - DropdownItem, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@calcom/ui/components/dropdown"; -import { AddVariablesDropdown } from "@calcom/ui/components/editor"; -import { ToggleGroup, Switch } from "@calcom/ui/components/form"; -import { Label, TextArea, Input, TextField, Form, Select } from "@calcom/ui/components/form"; -import { Icon } from "@calcom/ui/components/icon"; -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetBody, - SheetFooter, -} from "@calcom/ui/components/sheet"; -import { showToast } from "@calcom/ui/components/toast"; -import { Tooltip } from "@calcom/ui/components/tooltip"; - -import { DYNAMIC_TEXT_VARIABLES } from "../lib/constants"; -import type { FormValues } from "../pages/workflow"; -import { TestPhoneCallDialog } from "./TestPhoneCallDialog"; -import { VoiceSelectionDialog } from "./VoiceSelectionDialog"; -import { WebCallDialog } from "./WebCallDialog"; - -// Utility functions for prompt display -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; -}; - -const restorePromptComplexity = (prompt: string): string => { - if (!prompt) return prompt; - - const restoredPrompt = prompt - .replace(/\bcheck_availability\b/g, "check_availability_{{eventTypeId}}") - .replace(/\bbook_appointment\b/g, "book_appointment_{{eventTypeId}}"); - - return restoredPrompt; -}; - -// Language options for the dropdown -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" }, -]; - -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(), -}); - -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(), -}); - -type AgentFormValues = z.infer; -type PhoneNumberFormValues = z.infer; -// 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; -// }; - -type AgentConfigurationSheetProps = { - open: boolean; - onOpenChange: (open: boolean) => void; - agentId?: string | null; - agentData?: RouterOutputs["viewer"]["aiVoiceAgent"]["get"]; - onUpdate: (data: AgentFormValues) => void; - readOnly?: boolean; - teamId?: number; - isOrganization?: boolean; - workflowId?: string; - workflowStepId?: number; - activeTab?: "prompt" | "phoneNumber"; - form: UseFormReturn; -}; - -export function AgentConfigurationSheet({ - open, - activeTab: _activeTab, - onOpenChange, - agentId, - agentData, - onUpdate, - readOnly = false, - teamId, - isOrganization = false, - workflowId, - workflowStepId: _workflowStepId, - form, -}: AgentConfigurationSheetProps) { - const { t } = useLocale(); - - const utils = trpc.useUtils(); - const [activeTab, setActiveTab] = useState<"prompt" | "phoneNumber">(_activeTab ?? "prompt"); - 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 [isVoiceDialogOpen, setIsVoiceDialogOpen] = useState(false); - const [cancellingNumberId, setCancellingNumberId] = useState(null); - const [numberToDelete, setNumberToDelete] = useState(null); - // const [toolDialogOpen, setToolDialogOpen] = useState(false); - // const [editingToolIndex, setEditingToolIndex] = useState(null); - // const [toolDraft, setToolDraft] = useState(null); - - const generalPromptRef = useRef(null); - - const agentForm = useForm({ - resolver: zodResolver(agentSchema), - defaultValues: { - generalPrompt: "", - beginMessage: "", - numberToCall: "", - language: "en-US", - voiceId: "", - generalTools: [], - }, - }); - - useEffect(() => { - if (agentData?.retellData) { - const retellData = agentData.retellData; - agentForm.reset({ - generalPrompt: cleanPromptForDisplay(retellData.generalPrompt || ""), - beginMessage: retellData.beginMessage || "", - numberToCall: "", - language: retellData.language || "en-US", - voiceId: retellData.voiceId || "", - generalTools: retellData.generalTools || [], - }); - } - }, [agentData, agentForm]); - - const phoneNumberForm = useForm({ - resolver: zodResolver(phoneNumberFormSchema), - defaultValues: { - phoneNumber: "", - terminationUri: "", - sipTrunkAuthUsername: "", - sipTrunkAuthPassword: "", - nickname: "", - }, - }); - - // const { - // fields: toolFields, - // append: appendTool, - // remove: removeTool, - // update: updateTool, - // } = useFieldArray({ - // control: agentForm.control, - // name: "generalTools", - // }); - - 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 (_data: unknown) => { - showToast(t("phone_number_imported_successfully"), "success"); - setIsImportDialogOpen(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 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 agentQuery = trpc.viewer.aiVoiceAgent.get.useQuery( - { id: agentId || "" }, - { - enabled: !!agentId, - refetchOnMount: false, - } - ); - - const updateAgentMutation = trpc.viewer.aiVoiceAgent.update.useMutation({ - onSuccess: () => { - if (agentId) { - agentQuery.refetch(); - } - }, - onError: (error: { message: string }) => { - showToast(error.message, "error"); - }, - }); - - const handleAgentUpdate = async (data: AgentFormValues) => { - if (!agentId) return; - - // Only send prompt-related fields, not tools - const updatePayload = { - generalPrompt: restorePromptComplexity(data.generalPrompt), - beginMessage: data.beginMessage, - language: data.language as Language, - voiceId: data.voiceId, - // Don't include generalTools - they are managed by the backend - }; - - await updateAgentMutation.mutateAsync({ - id: agentId, - teamId: teamId, - ...updatePayload, - }); - - onUpdate(updatePayload); - }; - - // const openAddToolDialog = () => { - // setEditingToolIndex(null); - // setToolDraft({ - // type: "check_availability_cal", - // name: "", - // description: "", - // cal_api_key: null, - // event_type_id: null, - // timezone: "", - // }); - // setToolDialogOpen(true); - // }; - - // const openEditToolDialog = (idx: number) => { - // const tool = toolFields[idx]; - // if (tool) { - // setEditingToolIndex(idx); - // setToolDraft({ ...tool }); - // setToolDialogOpen(true); - // } - // }; - - // const handleToolDialogSave = () => { - // if (!toolDraft?.name || !toolDraft?.type) return; - - // if (toolDraft.type === "check_availability_cal" || toolDraft.type === "book_appointment_cal") { - // if (!toolDraft.cal_api_key) { - // showToast(t("API Key is required for Cal.com tools"), "error"); - // return; - // } - // if (!toolDraft.event_type_id) { - // showToast(t("Event Type ID is required for Cal.com tools"), "error"); - // return; - // } - // if (!toolDraft.timezone) { - // showToast(t("Timezone is required for Cal.com tools"), "error"); - // return; - // } - // } - - // if (editingToolIndex !== null) { - // updateTool(editingToolIndex, toolDraft); - // } else { - // appendTool(toolDraft); - // } - // setToolDialogOpen(false); - // setToolDraft(null); - // setEditingToolIndex(null); - // }; - - // const handleToolDelete = (idx: number) => { - // removeTool(idx); - // }; - - 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); - }; - - const handleCancelSubscription = (phoneNumberId: number) => { - setCancellingNumberId(phoneNumberId); - }; - - const handleDeletePhoneNumber = (phoneNumber: string) => { - setNumberToDelete(phoneNumber); - }; - - const confirmCancelSubscription = () => { - if (cancellingNumberId) { - cancelSubscriptionMutation.mutate({ phoneNumberId: cancellingNumberId }); - } - }; - - const confirmDeletePhoneNumber = () => { - if (numberToDelete) { - deletePhoneNumberMutation.mutate({ phoneNumber: numberToDelete }); - } - }; - - 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)}`; - - agentForm.setValue("generalPrompt", newPrompt, { shouldDirty: true, shouldTouch: true }); - - requestAnimationFrame(() => { - if (generalPromptRef.current) { - generalPromptRef.current.focus(); - generalPromptRef.current.setSelectionRange( - cursorPosition + variableName.length, - cursorPosition + variableName.length - ); - } - }); - } - }; - - return ( - <> - - - - {t("cal_ai_agent_configuration")} - { - setActiveTab((val || "prompt") as "prompt" | "phoneNumber"); - }} - value={activeTab} - options={[ - { value: "prompt", label: t("prompt") }, - { value: "phoneNumber", label: t("phone_number") }, - ]} - isFullWidth={true} - /> - - - {activeTab === "prompt" && ( -
-
- - ( - -
-
-
- -

{t("general_prompt_description")}

-
-
- {!readOnly && ( - - )} -
-