diff --git a/.env.example b/.env.example index f01c94434413e6..392bfcd412bb65 100644 --- a/.env.example +++ b/.env.example @@ -383,10 +383,13 @@ TASKER_ENABLE_EMAILS=0 # Ratelimiting via unkey UNKEY_ROOT_KEY= -# Used for Cal.ai Enterprise Voice AI Agents +# Used for Cal.ai Voice AI Agents # https://retellai.com RETELL_AI_KEY= +# Used for buying phone number for cal ai voice agent +STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID= + # Used for the huddle01 integration HUDDLE01_API_TOKEN= diff --git a/apps/web/app/api/phone-numbers/subscription/success/route.ts b/apps/web/app/api/phone-numbers/subscription/success/route.ts new file mode 100644 index 00000000000000..b8b9a6989d327a --- /dev/null +++ b/apps/web/app/api/phone-numbers/subscription/success/route.ts @@ -0,0 +1,94 @@ +import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import type Stripe from "stripe"; +import { z } from "zod"; + +import stripe from "@calcom/features/ee/payments/server/stripe"; +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { HttpError } from "@calcom/lib/http-error"; + +const querySchema = z.object({ + session_id: z.string().min(1), +}); + +const checkoutSessionMetadataSchema = z.object({ + userId: z.string().transform(Number), + teamId: z + .string() + .optional() + .transform((val) => (val ? Number(val) : undefined)), + eventTypeId: z + .string() + .optional() + .transform((val) => (val ? Number(val) : undefined)), + agentId: z.string().optional(), + workflowId: z.string().optional(), + type: z.literal("phone_number_subscription"), +}); + +type CheckoutSessionMetadata = z.infer; + +async function handler(request: NextRequest) { + try { + const { session_id } = querySchema.parse(Object.fromEntries(request.nextUrl.searchParams)); + const checkoutSession = await getCheckoutSession(session_id); + const metadata = validateAndExtractMetadata(checkoutSession); + + return redirectToSuccess(metadata); + } catch (error) { + return handleError(error); + } +} + +async function getCheckoutSession(sessionId: string) { + const session = await stripe.checkout.sessions.retrieve(sessionId, { expand: ["subscription"] }); + if (!session) { + throw new HttpError({ statusCode: 404, message: "Checkout session not found" }); + } + return session; +} + +function validateAndExtractMetadata(session: Stripe.Checkout.Session): CheckoutSessionMetadata { + if (session.payment_status !== "paid") { + throw new HttpError({ statusCode: 402, message: "Payment required" }); + } + if (!session.subscription) { + throw new HttpError({ statusCode: 400, message: "No subscription found in checkout session" }); + } + + const result = checkoutSessionMetadataSchema.safeParse(session.metadata); + if (!result.success) { + throw new HttpError({ + statusCode: 400, + message: `Invalid checkout session metadata: ${result.error}`, + }); + } + + return result.data; +} + +function redirectToSuccess(metadata: CheckoutSessionMetadata) { + const basePath = metadata.workflowId + ? `${WEBAPP_URL}/workflows/${metadata.workflowId}` + : `${WEBAPP_URL}/workflows`; + + return NextResponse.redirect(basePath); +} + +function handleError(error: unknown) { + console.error("Error handling phone number subscription success:", error); + + const url = new URL(`${WEBAPP_URL}/workflows`); + url.searchParams.set("error", "true"); + + if (error instanceof HttpError) { + url.searchParams.set("message", error.message); + } else { + url.searchParams.set("message", "An error occurred while processing your subscription"); + } + + return NextResponse.redirect(url.toString()); +} + +export const GET = defaultResponderForAppDir(handler); diff --git a/apps/web/app/api/webhooks/retell-ai/route.ts b/apps/web/app/api/webhooks/retell-ai/route.ts new file mode 100644 index 00000000000000..3c399659eaf234 --- /dev/null +++ b/apps/web/app/api/webhooks/retell-ai/route.ts @@ -0,0 +1,194 @@ +import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { Retell } from "retell-sdk"; +import { z } from "zod"; + +import { CreditService } from "@calcom/features/ee/billing/credit-service"; +import { RETELL_API_KEY } from "@calcom/lib/constants"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { prisma } from "@calcom/prisma"; + +const log = logger.getSubLogger({ prefix: ["retell-ai-webhook"] }); + +const RetellWebhookSchema = z.object({ + event: z.enum(["call_started", "call_ended", "call_analyzed"]), + call: z + .object({ + call_id: z.string(), + agent_id: z.string().optional(), + from_number: z.string(), + to_number: z.string(), + direction: z.enum(["inbound", "outbound"]), + call_status: z.string(), + start_timestamp: z.number(), + end_timestamp: z.number().optional(), + disconnection_reason: z.string().optional(), + metadata: z.record(z.any()).optional(), + retell_llm_dynamic_variables: z.record(z.any()).optional(), + transcript: z.string().optional(), + opt_out_sensitive_data_storage: z.boolean().optional(), + call_cost: z + .object({ + product_costs: z + .array( + z.object({ + product: z.string(), + unitPrice: z.number().optional(), + cost: z.number().optional(), + }) + ) + .optional(), + total_duration_seconds: z.number().optional(), + total_duration_unit_price: z.number().optional(), + total_one_time_price: z.number().optional(), + combined_cost: z.number().optional(), + }) + .optional(), + call_analysis: z + .object({ + call_summary: z.string().optional(), + in_voicemail: z.boolean().optional(), + user_sentiment: z.string().optional(), + call_successful: z.boolean().optional(), + custom_analysis_data: z.record(z.any()).optional(), + }) + .optional(), + }) + .passthrough(), +}); + +async function handleCallAnalyzed(callData: any) { + const { from_number, call_id, call_cost } = callData; + if (!call_cost || typeof call_cost.combined_cost !== "number") { + log.error(`No call_cost.combined_cost in payload for call ${call_id}`); + return; + } + + const phoneNumber = await prisma.calAiPhoneNumber.findFirst({ + where: { phoneNumber: from_number }, + include: { + user: { select: { id: true, email: true, name: true } }, + team: { select: { id: true, name: true } }, + }, + }); + + if (!phoneNumber) { + log.error(`No phone number found for ${from_number}, cannot deduct credits`); + return; + } + + // Support both personal and team phone numbers + const userId = phoneNumber.userId; + const teamId = phoneNumber.teamId; + + if (!userId && !teamId) { + log.error(`Phone number ${from_number} has no associated user or team`); + return; + } + + const baseCost = call_cost.combined_cost; // in cents + const creditsToDeduct = Math.ceil(baseCost * 1.8); + + const creditService = new CreditService(); + const hasCredits = await creditService.hasAvailableCredits({ userId, teamId }); + if (!hasCredits) { + log.error( + `${ + teamId ? `Team ${teamId}` : `User ${userId}` + } has insufficient credits for call ${call_id} (${creditsToDeduct} credits needed)` + ); + return; + } + + await creditService.chargeCredits({ + userId: userId ?? undefined, + teamId: teamId ?? undefined, + credits: creditsToDeduct, + }); + + return { + success: true, + message: `Successfully charged ${creditsToDeduct} credits for ${ + teamId ? `team ${teamId}` : `user ${userId}` + }, call ${call_id} (base cost: ${baseCost} cents)`, + }; +} + +/** + * Retell AI Webhook Handler + * + * Setup Instructions: + * 1. Add this webhook URL to your Retell AI dashboard: https://yourdomain.com/api/webhooks/retell-ai + * 2. Ensure your domain is accessible from the internet (for local development, use ngrok or similar) + * 3. Set the RETELL_API_KEY environment variable with your Retell API key (must have webhook badge) + * + * This webhook will: + * - Verify webhook signature for security + * - Receive call_analyzed events from Retell AI + * - Charge credits based on the call cost from the user's or team's credit balance + * - Log all transactions for audit purposes + */ +async function handler(request: NextRequest) { + // Get the raw body for signature verification + const rawBody = await request.text(); + const body = JSON.parse(rawBody); + + // Verify webhook signature + const signature = request.headers.get("x-retell-signature"); + const apiKey = RETELL_API_KEY; + + if (!signature || !apiKey) { + log.error("Missing signature or API key for webhook verification"); + return NextResponse.json( + { + error: "Unauthorized", + message: "Missing signature or API key", + }, + { status: 401 } + ); + } + + if (!Retell.verify(rawBody, apiKey, signature)) { + log.error("Invalid webhook signature"); + return NextResponse.json( + { + error: "Unauthorized", + message: "Invalid signature", + }, + { status: 401 } + ); + } + + if (body.event !== "call_analyzed") { + return NextResponse.json({ + success: true, + message: `No handling for ${body.event} for call ${body.call?.call_id ?? "unknown"}`, + }); + } + + try { + const payload = RetellWebhookSchema.parse(body); + const callData = payload.call; + log.info(`Received Retell AI webhook: ${payload.event} for call ${callData.call_id}`); + + const result = await handleCallAnalyzed(callData); + + return NextResponse.json({ + success: true, + message: result?.message ?? `Processed ${payload.event} for call ${callData.call_id}`, + }); + } catch (error) { + log.error("Error processing Retell AI webhook:", safeStringify(error)); + return NextResponse.json( + { + error: "Internal server error", + message: error instanceof Error ? error.message : "Unknown error", + }, + { status: 500 } + ); + } +} + +export const POST = defaultResponderForAppDir(handler); diff --git a/apps/web/package.json b/apps/web/package.json index 5ebf8198cd3d88..3ef96c594e824b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -138,6 +138,7 @@ "react-use-intercom": "1.5.1", "recoil": "^0.7.7", "remove-markdown": "^0.5.0", + "retell-sdk": "^4.40.0", "rrule": "^2.7.1", "sanitize-html": "^2.10.0", "schema-dts": "^1.1.0", diff --git a/apps/web/pages/api/trpc/ai/[trpc].ts b/apps/web/pages/api/trpc/ai/[trpc].ts new file mode 100644 index 00000000000000..cd2d44a6860638 --- /dev/null +++ b/apps/web/pages/api/trpc/ai/[trpc].ts @@ -0,0 +1,4 @@ +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; +import { aiRouter } from "@calcom/trpc/server/routers/viewer/ai/_router"; + +export default createNextApiHandler(aiRouter); diff --git a/apps/web/pages/api/trpc/phoneNumber/[trpc].ts b/apps/web/pages/api/trpc/phoneNumber/[trpc].ts new file mode 100644 index 00000000000000..a94746be2106a3 --- /dev/null +++ b/apps/web/pages/api/trpc/phoneNumber/[trpc].ts @@ -0,0 +1,4 @@ +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; +import { phoneNumberRouter } from "@calcom/trpc/server/routers/viewer/phoneNumber/_router"; + +export default createNextApiHandler(phoneNumberRouter); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index e9e7a07c8d1e41..5d8a8d701beab5 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -17,6 +17,24 @@ "verify_email_subject": "{{appName}}: Verify your account", "verify_email_subject_verifying_email": "{{appName}}: Verify your email", "check_your_email": "Check your email", + "termination_uri": "Termination URI", + "sip_trunk_username": "SIP Trunk User Name", + "sip_trunk_password": "SIP Trunk Password", + "nickname": "Nickname", + "delete_phone_number": "Delete Phone Number", + "workflow_validation_failed": "Workflow validation failed", + "workflow_validation_empty_fields": "One or more workflow steps have empty message content", + "workflow_validation_unverified_contacts": "One or more phone numbers or email addresses are not verified", + "phone_number_imported_successfully": "Phone number imported and linked to agent successfully", + "phone_number_deleted_successfully": "Phone number deleted successfully", + "delete_phone_number_confirmation": "Are you sure you want to delete this phone number? This action cannot be undone.", + "yes_delete_phone_number": "Yes, delete phone number", + "phone_number_info_tooltip": "The number you are trying to import in E.164 format of the number (+country code, then number with no space, no special characters), e.g. +1234567890", + "termination_uri_info_tooltip": "The termination uri to uniquely identify your elastic SIP trunk. This is used for outbound calls. For Twilio elastic SIP trunks it always end with .pstn.twilio.com.", + "sip_trunk_username_info_tooltip": "The username used for authentication for the SIP trunk.", + "sip_trunk_password_info_tooltip": "The password used for authentication for the SIP trunk.", + "nickname_info_tooltip": "Nickname of the number. This is for your reference only.", + "save_and_test_call": "Save and Test Call", "old_email_address": "Old Email", "new_email_address": "New Email", "verify_email_page_body": "We've sent an email to {{email}}. It is important to verify your email address to guarantee the best email and calendar deliverability from {{appName}}.", @@ -24,6 +42,11 @@ "verify_email_email_header": "Verify your email address", "verify_email_button": "Verify email", "cal_ai_assistant": "Cal AI Assistant", + "call_initiated_successfully": "Call initiated successfully", + "please_enter_phone_number": "Please enter a phone number", + "agent_updated_successfully": "Agent updated successfully", + "agent_created_successfully": "Agent created successfully", + "phone_number_unsubscribed_successfully": "Phone number unsubscribed successfully", "send_cal_video_transcription_emails": "Send Cal Video Transcription Emails", "description_send_cal_video_transcription_emails": "Send emails with the transcription of the Cal Video after the meeting ends. (Requires a paid plan)", "verify_email_change_description": "You have recently requested to change the email address you use to log into your {{appName}} account. Please click the button below to confirm your new email address.", @@ -129,6 +152,8 @@ "missing_card_fields": "Missing card fields", "pay_now": "Pay now", "general_prompt": "General Prompt", + "general_prompt_description": "This prompt defines the agent's role and primary objectives", + "prompt": "Prompt", "begin_message": "Begin Message", "codebase_has_to_stay_opensource": "The codebase has to stay open source, whether it was modified or not", "cannot_repackage_codebase": "You can not repackage or sell the codebase", @@ -149,6 +174,8 @@ "confirm_or_reject_request": "Confirm or reject the request", "check_bookings_page_to_confirm_or_reject": "Check your bookings page to confirm or reject the booking.", "check_in_assistant": "Check-in Assistant", + "call_to": "Call to", + "make_test_call_to_verify_configuration": "Make Test Call to Verify Configuration", "check_in_assistant_description": "Makes an outbound call to check if scheduled appointment works or if you want to reschedule.", "create_your_own_prompt": "Create your own prompt and use it to make an outbound call.", "event_awaiting_approval": "An event is waiting for your approval", @@ -274,7 +301,10 @@ "2fa_scan_image_or_use_code": "Scan the image below with the authenticator app on your phone or manually enter the text code instead.", "text": "Text", "your_phone_number": "Your Phone Number", + "initial_message": "Initial Message", + "initial_message_description": "The first message the agent will say when starting a call", "multiline_text": "Multiline Text", + "hi_how_are_you_doing": "Hi, how are you doing?", "number": "Number", "checkbox": "Checkbox", "is_required": "Is required", @@ -293,6 +323,9 @@ "is_still_available": "is still available.", "documentation": "Documentation", "documentation_description": "Learn how to integrate our tools with your app", + "having_trouble_importing": "Having trouble importing?", + "learn": "Learn", + "learn_how_to_get_your_terminator": "Learn how to get your Terminator and SIP", "api_reference": "API Reference", "api_reference_description": "A complete API reference for our libraries", "blog": "Blog", @@ -596,9 +629,11 @@ "password": "Password", "password_updated_successfully": "Password updated successfully", "password_has_been_changed": "Your password has been successfully changed.", + "phone_number_subscription_cancelled_successfully": "Phone number subscription cancelled successfully", "error_changing_password": "Error changing password", "session_timeout_changed": "Your session configuration has been updated successfully.", "session_timeout_change_error": "Error updating session configuration", + "updating": "Updating", "something_went_wrong": "Something went wrong.", "something_doesnt_look_right": "Something doesn't look right?", "please_try_again": "Please try again.", @@ -675,6 +710,7 @@ "attendee_phone_number": "Attendee Phone Number", "organizer_phone_number": "Organizer Phone Number", "enter_phone_number": "Enter phone number", + "enter_phone_number_to_test_call": "Enter phone number to test call", "reschedule": "Reschedule", "reschedule_this": "Reschedule instead", "book_a_team_member": "Book a team member instead", @@ -1235,6 +1271,7 @@ "make_org_private_description": "Your organization members won't be able to see other organization members when this is turned on.", "make_team_private": "Make team private", "make_team_private_description": "Your team members won't be able to see other team members when this is turned on.", + "make_test_call": "Make Test Call", "you_cannot_see_team_members": "You cannot see all the team members of a private team.", "you_cannot_see_teams_of_org": "You cannot see teams of a private organization.", "allow_booker_to_select_duration": "Allow booker to select duration", @@ -1516,9 +1553,12 @@ "no_workflows_description": "Workflows enable simple automation to send notifications & reminders enabling you to build processes around your events.", "timeformat_profile_hint": "This is an internal setting and will not affect how times are displayed on public booking pages for you or anyone booking you.", "create_workflow": "Create a workflow", + "create_new_workflow_agent": "Create new workflow cal.ai voice agent", + "create_new_workflow_agent_description": "Create a new workflow cal.ai voice agent to automate your workflows", "do_this": "Do this", "turn_off": "Turn off", "turn_on": "Turn on", + "phone_number_for_ai_call": "Phone number (for our agent to call)", "cancelled_bookings_cannot_be_rescheduled": "Canceled bookings cannot be rescheduled", "settings_updated_successfully": "Settings updated successfully", "error_updating_settings": "Error updating settings", @@ -1699,6 +1739,9 @@ "api_keys_description": "Generate API keys for accessing your own account", "new_api_key": "New API key", "active": "active", + "you_can_only_call_numbers_in_the_us": "You can only call numbers in the US", + "buy_a_phone_number_or_import_one_you_already_have": "Buy a phone number or import one you already have", + "test_agent": "Test Agent", "api_key_updated": "API key name updated", "api_key_update_failed": "Error updating API key name", "embeds_title": "HTML iframe embed", @@ -1732,6 +1775,26 @@ "booker_booking_limit_description": "Limit the number of active bookings a booker can make for this event type", "recurring_event_doesnt_support_booker_booking_limit": "Recurring events don't support booker booking limit", "add_limit": "Add Limit", + "phone_numbers": "Phone Numbers", + "unsubscribe": "Unsubscribe", + "delete_workflow_step": "Delete Workflow Step", + "unsubscribe_phone_number": "Unsubscribe Phone Number", + "no_phone_number_connected":"No phone number connected", + "failed_to_get_workflow_step_id": "Failed to get workflow step ID", + "test_cal_ai_agent": "Test Cal.ai Agent", + "are_you_sure_you_want_to_delete_workflow_step": "Are you sure you want to delete this workflow step?", + "are_you_still_want_to_unsubscribe": "Are you sure you want to unsubscribe the phone number from this agent?", + "the_action_will_disconnect_phone_number": "This action will disconnect the phone number from the agent. The agent will not be able to make calls until a new phone number is connected.", + "cal_ai_phone_numbers": "Cal AI Phone Numbers", + "cal_ai_phone_numbers_description": "Manage your Cal AI Phone Numbers", + "import_number": "Import Number", + "this_action_will_also": "This action will also:", + "import_phone_number": "Import Phone Number", + "active_subscription": "Active Subscription", + "cancel_your_phone_number_subscription": "Cancel your phone number subscription", + "delete_associated_phone_number": "Delete the associated phone number", + "unauthorized_create_workflow": "You are not authorized to create this workflow", + "import_phone_number_description": "Import your Twilio phone number to use with Cal AI Phone", "team_name_required": "Team name required", "show_attendees": "Share attendee information between guests", "show_available_seats_count": "Show the number of available seats", @@ -1976,6 +2039,7 @@ "repackage_rebrand_resell": "Repackage, rebrand and resell easily", "a_vast_suite_of_enterprise_features": "A vast suite of enterprise features", "free_license_fee": "$0.00/month", + "phone_number_cost": "${{price}}/month", "forever_open_and_free": "Forever Open & Free", "required_to_keep_your_code_open_source": "Required to keep your code open source", "cannot_repackage_and_resell": "Cannot repackage, rebrand and resell easily", @@ -3065,6 +3129,9 @@ "buy_credits": "Buy Credits", "credits": "Credits", "view_and_manage_credits": "View and manage credits", + "buy_new_number": "Buy New Number", + "buy_number_cost_5_per_month": "Buying a phone number costs $5 per month. You will be charged monthly for each active phone number.", + "area_code_optional": "Area Code (Optional)", "view_and_manage_credits_description": "View and manage credits for sending SMS messages. One credit is worth 1¢ (USD). <0>Learn more", "buy_additional_credits": "Buy additional credits ($0.01 per credit)", "overview": "Overview", @@ -3370,6 +3437,12 @@ "booking_not_allowed_by_restriction_schedule_error": "Booking outside restriction schedule availability.", "restriction_schedule_not_found_error": "Restriction schedule not found", "converted_image_size_limit_exceed": "Image size limit exceeded, please use a smaller image preferably in JPEG format", + "buy_number_for_5_per_month": "Buy Number for $5/month", + "no_phone_numbers": "No phone numbers", + "phone_number_purchased_successfully": "Phone number purchased successfully", + "cancel_phone_number_subscription": "Cancel Phone Number Subscription", + "yes_cancel_subscription": "Yes, Cancel Subscription", + "cancel_phone_number_subscription_confirmation": "Are you sure you want to cancel this phone number subscription? This action cannot be undone and you will lose access to this phone number.", "routing_funnel": "Routing Funnel", "routing_funnel_total_submissions": "Total Submissions", "routing_funnel_successful_routings": "Successful Routings", diff --git a/docs/ai-voice-call-credits.md b/docs/ai-voice-call-credits.md new file mode 100644 index 00000000000000..120da08590e144 --- /dev/null +++ b/docs/ai-voice-call-credits.md @@ -0,0 +1,181 @@ +# AI Voice Call Credits + +## Overview + +This document describes the credit system for AI voice calls in Cal.com. The system required with a **minimum requirement of 5 credits** to initiate any phone call. + +## Credit Requirements + +- **Minimum Credits**: Users must have at least 5 credits available to initiate a phone call +- **Credit Check**: The system performs credit validation both on the frontend and backend before allowing calls + +## How It Works + +### Credit Validation + +1. **Frontend Check**: Before initiating a call, the UI validates that the user has at least 5 credits +2. **Backend Check**: The API endpoint `createPhoneCall` validates credits before processing the call +3. **Real-time Charging**: Once calls complete, credits are charged via the Retell AI webhook + + +## Integration with Retell AI + +The credit system integrates with Retell AI webhooks to charge credits based on actual call duration: + +### Setup Instructions + +1. **Configure Retell AI Webhook** + - Go to your [Retell AI Dashboard](https://dashboard.retellai.com) + - Navigate to **Settings** → **Webhooks** + - Add a new webhook with URL: `https://yourdomain.com/api/webhooks/retell-ai` + - Enable events: `call_ended` + +2. **Environment Configuration** + - Valid Retell AI API credentials + - Credit system enabled (`IS_SMS_CREDITS_ENABLED=true`) + - Database properly configured with credit tables + +### Credit Deduction Logic + +1. **Pre-call Validation**: Ensures user has at least 5 credits before call initiation +2. **Call Processing**: Retell AI handles the actual phone call +3. **Post-call Charging**: Webhook charges credits based on actual duration +4. **Audit Logging**: Records all transactions for accountability + +### Excluded Calls + +The system will **NOT** charge credits for: +- Calls with missing timestamps +- Calls that were cancelled before completion +- Calls that ended due to errors +- Calls with zero or negative duration + +## Webhook Events + +### `call_started` +- **Action**: Logs call initiation +- **Billing**: No charges applied + +### `call_ended` +- **Action**: Calculates duration and charges credits +- **Billing**: 1 credit per minute (rounded up) + +### `call_analyzed` +- **Action**: Logs call analysis completion +- **Billing**: No charges applied + +## Error Handling + +### Common Scenarios + +1. **Insufficient Credits (Pre-call)**: Error shown to user, call prevented +2. **User Not Found**: Logs error, no charges applied +3. **Invalid Duration**: Logs warning, no charges applied +4. **Database Errors**: Logs error, webhook returns 500 status + +### User Experience + +- **Frontend**: Shows detailed error messages about credit requirements +- **Backend**: Validates credits before processing calls +- **Billing**: Only charges for successfully completed calls + +## Integration with Existing Credit System + +This system leverages the existing SMS credit infrastructure: + +- **Credit Service**: Uses `CreditService.chargeCredits()` +- **Database**: Stores in existing `CreditExpenseLog` table +- **Billing**: Integrates with existing billing and subscription logic +- **Notifications**: Supports low credit warnings and limits + +## Monitoring and Debugging + +### Logs to Monitor + +```javascript +// Successful credit charge +"Successfully charged X credits for user Y, call Z (N minutes)" + +// Insufficient credits (pre-call) +"User X has insufficient credits for call Y (5 credits needed)" + +// Invalid call data +"Call X missing timestamps, skipping credit deduction" +``` + +### Health Checks + +1. **Webhook Status**: Monitor webhook delivery in Retell AI dashboard +2. **Credit Balance**: Check user credit balances regularly +3. **Expense Logs**: Review `CreditExpenseLog` table for accuracy +4. **Error Rates**: Monitor webhook error responses + +## API Reference + +### Webhook Endpoint + +```http +POST /api/webhooks/retell-ai +``` + +### Expected Payload + +```json +{ + "event": "call_ended", + "call": { + "call_id": "call_123", + "from_number": "+1234567890", + "to_number": "+0987654321", + "start_timestamp": 1640995200000, + "end_timestamp": 1640995320000, + "disconnection_reason": "user_hung_up" + } +} +``` + +### Response + +```json +{ + "success": true, + "message": "Processed call_ended for call call_123" +} +``` + +## Troubleshooting + +### Webhook Not Receiving Events + +1. Verify webhook URL is correct and accessible +2. Check Retell AI dashboard for webhook delivery status +3. Ensure your server is running and responding +4. Check firewall/security settings + +### Credits Not Being Charged + +1. Check user has sufficient credits +2. Verify phone number is associated with a user +3. Review logs for error messages +4. Check call duration calculations + +### User Cannot Make Calls + +1. Verify user has at least 5 credits +2. Check credit calculation (monthly + additional credits) +3. Review error messages in UI +4. Check backend validation logs + +## Security Considerations + +- **Webhook Authentication**: Consider adding signature verification +- **Rate Limiting**: Implement rate limiting on webhook endpoint +- **Input Validation**: All payloads are validated with Zod schemas +- **Error Handling**: Sensitive data is not exposed in error responses + +## Future Enhancements + +- **Webhook Signature Verification**: Add Retell AI signature validation +- **Credit Thresholds**: Different rates for different call types +- **Usage Analytics**: Dashboard for call usage and costs +- **Real-time Notifications**: Alert users when credits are low during calls diff --git a/packages/app-store/stripepayment/lib/constants.ts b/packages/app-store/stripepayment/lib/constants.ts index 5499427359e11d..1f92b1b4e93597 100644 --- a/packages/app-store/stripepayment/lib/constants.ts +++ b/packages/app-store/stripepayment/lib/constants.ts @@ -1,6 +1,7 @@ export const PREMIUM_MONTHLY_PLAN_PRICE = process.env.NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE_MONTHLY || ""; export const PREMIUM_PLAN_PRODUCT_ID = process.env.NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRODUCT_ID || ""; export const STRIPE_TEAM_MONTHLY_PRICE_ID = process.env.NEXT_PUBLIC_STRIPE_TEAM_MONTHLY_PRICE_ID || ""; +export const STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID = process.env.STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID || ""; export const paymentOptions = [ { diff --git a/packages/app-store/stripepayment/lib/utils.ts b/packages/app-store/stripepayment/lib/utils.ts index 3fc19527ce8260..3d1d563c4b1a0a 100644 --- a/packages/app-store/stripepayment/lib/utils.ts +++ b/packages/app-store/stripepayment/lib/utils.ts @@ -3,6 +3,7 @@ import { PREMIUM_PLAN_PRODUCT_ID, STRIPE_TEAM_MONTHLY_PRICE_ID, PREMIUM_MONTHLY_PLAN_PRICE, + STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID, } from "./constants"; export const getPremiumMonthlyPlanPriceId = (): string => { @@ -17,6 +18,13 @@ export function getPerSeatPlanPrice(): string { return STRIPE_TEAM_MONTHLY_PRICE_ID; } +export function getPhoneNumberMonthlyPriceId(): string { + if (!STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID) { + throw new Error("STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID env var is not set"); + } + return STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID; +} + export function getPremiumPlanPriceValue() { return "$29/month"; } diff --git a/packages/features/bookings/lib/SystemField.ts b/packages/features/bookings/lib/SystemField.ts index 8538fc68b38a35..4e1af78b98acde 100644 --- a/packages/features/bookings/lib/SystemField.ts +++ b/packages/features/bookings/lib/SystemField.ts @@ -10,7 +10,9 @@ export const SystemField = z.enum([ "rescheduleReason", "smsReminderNumber", "attendeePhoneNumber", + "aiAgentCallPhoneNumber", ]); export const SMS_REMINDER_NUMBER_FIELD = "smsReminderNumber"; +export const CAL_AI_AGENT_PHONE_NUMBER_FIELD = "aiAgentCallPhoneNumber"; export const TITLE_FIELD = "title"; diff --git a/packages/features/bookings/lib/getBookingFields.ts b/packages/features/bookings/lib/getBookingFields.ts index 6fce4bc9ea3dd2..162073b8808daf 100644 --- a/packages/features/bookings/lib/getBookingFields.ts +++ b/packages/features/bookings/lib/getBookingFields.ts @@ -1,7 +1,10 @@ import type { EventTypeCustomInput, EventType } from "@prisma/client"; import type { z } from "zod"; -import { SMS_REMINDER_NUMBER_FIELD } from "@calcom/features/bookings/lib/SystemField"; +import { + SMS_REMINDER_NUMBER_FIELD, + CAL_AI_AGENT_PHONE_NUMBER_FIELD, +} from "@calcom/features/bookings/lib/SystemField"; import type { Workflow } from "@calcom/features/ee/workflows/lib/types"; import { fieldsThatSupportLabelAsSafeHtml } from "@calcom/features/form-builder/fieldsThatSupportLabelAsSafeHtml"; import { getFieldIdentifier } from "@calcom/features/form-builder/utils/getFieldIdentifier"; @@ -52,6 +55,29 @@ export const getSmsReminderNumberSource = ({ editUrl: `/workflows/${workflowId}`, }); +export const getAIAgentCallPhoneNumberField = () => + ({ + name: CAL_AI_AGENT_PHONE_NUMBER_FIELD, + type: "phone", + defaultLabel: "phone_number_for_ai_call", + defaultPlaceholder: "enter_phone_number", + editable: "system", + } as const); + +export const getAIAgentCallPhoneNumberSource = ({ + workflowId, + isAIAgentCallPhoneNumberRequired, +}: { + workflowId: Workflow["id"]; + isAIAgentCallPhoneNumberRequired: boolean; +}) => ({ + id: `${workflowId}`, + type: "workflow", + label: "Workflow", + fieldRequired: isAIAgentCallPhoneNumberRequired, + editUrl: `/workflows/${workflowId}`, +}); + /** * This fn is the key to ensure on the fly mapping of customInputs to bookingFields and ensuring that all the systems fields are present and correctly ordered in bookingFields */ 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 3c5c3baca5d8a6..6b1ec9cf48052b 100644 --- a/packages/features/ee/billing/api/webhook/_checkout.session.completed.ts +++ b/packages/features/ee/billing/api/webhook/_checkout.session.completed.ts @@ -1,11 +1,22 @@ +import { createDefaultAIPhoneServiceProvider } from "@calcom/features/ee/cal-ai-phone"; import stripe from "@calcom/features/ee/payments/server/stripe"; +import { AgentRepository } from "@calcom/lib/server/repository/agent"; import { CreditsRepository } from "@calcom/lib/server/repository/credits"; +import { prisma } from "@calcom/prisma"; +import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums"; import type { SWHMap } from "./__handler"; import { HttpCode } from "./__handler"; const handler = async (data: SWHMap["checkout.session.completed"]["data"]) => { const session = data.object; + + // Handle phone number subscriptions + if (session.metadata?.type === "phone_number_subscription") { + return await handlePhoneNumberSubscription(session); + } + + // Handle credit purchases (existing logic) if (!session.amount_total) { throw new HttpCode(400, "Missing required payment details"); } @@ -64,4 +75,80 @@ async function saveToCreditBalance({ }); } } + +async function handlePhoneNumberSubscription(session: any) { + const userId = session.metadata?.userId ? parseInt(session.metadata.userId, 10) : null; + const teamId = session.metadata?.teamId ? parseInt(session.metadata.teamId, 10) : null; + const agentId = session.metadata?.agentId || null; + + if (!userId || !session.subscription) { + throw new HttpCode(400, "Missing required data for phone number subscription"); + } + + const aiService = createDefaultAIPhoneServiceProvider(); + + const retellPhoneNumber = await aiService.createPhoneNumber({ nickname: `${userId}-${Date.now()}` }); + + if (!retellPhoneNumber?.phone_number) { + throw new HttpCode(500, "Failed to create phone number - invalid response"); + } + + const subscriptionId = + typeof session.subscription === "string" ? session.subscription : session.subscription?.id; + + if (!subscriptionId) { + throw new HttpCode(400, "Invalid subscription data"); + } + + const newNumber = await prisma.calAiPhoneNumber.create({ + data: { + userId, + teamId, + phoneNumber: retellPhoneNumber.phone_number, + provider: "retell", + stripeCustomerId: session.customer as string, + stripeSubscriptionId: subscriptionId, + subscriptionStatus: PhoneNumberSubscriptionStatus.ACTIVE, + }, + }); + + // If agentId is provided, link the phone number to the agent + if (agentId) { + try { + const agent = await AgentRepository.findByIdWithUserAccess({ + agentId, + userId, + }); + + if (!agent) { + throw new HttpCode(404, "Agent not found or user does not have access to it"); + } + + // Assign agent to the new number via Retell API + await aiService.updatePhoneNumber(retellPhoneNumber.phone_number, { + outbound_agent_id: agent.retellAgentId, + }); + + // Link the new number to the agent in our database + await prisma.calAiPhoneNumber.update({ + where: { id: newNumber.id }, + data: { + outboundAgent: { + connect: { id: agentId }, + }, + }, + }); + } catch (error) { + console.error("Agent linking error details:", { + error, + agentId, + phoneNumber: retellPhoneNumber.phone_number, + userId, + }); + } + } + + return { success: true, phoneNumber: newNumber.phoneNumber }; +} + export default handler; 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 ec04137915d4ef..a57505fcf26d33 100644 --- a/packages/features/ee/billing/api/webhook/_customer.subscription.deleted.ts +++ b/packages/features/ee/billing/api/webhook/_customer.subscription.deleted.ts @@ -1,4 +1,8 @@ +import { prisma } from "@calcom/prisma"; +import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums"; + import type { LazyModule, SWHMap } from "./__handler"; +import { HttpCode } from "./__handler"; type Data = SWHMap["customer.subscription.deleted"]["data"]; @@ -8,6 +12,22 @@ const STRIPE_TEAM_PRODUCT_ID = process.env.STRIPE_TEAM_PRODUCT_ID || ""; const stripeWebhookProductHandler = (handlers: Handlers) => async (data: Data) => { const subscription = data.object; + + // Check if this is a phone number subscription first + const phoneNumber = await prisma.calAiPhoneNumber.findFirst({ + where: { + stripeSubscriptionId: subscription.id, + }, + select: { + id: true, + }, + }); + + if (phoneNumber) { + return await handlePhoneNumberSubscriptionDeleted(subscription, phoneNumber); + } + + // Fall back to product-based handling for other subscriptions let productId: string | null = null; // @ts-expect-error - support legacy just in case. if (subscription.plan) { @@ -46,6 +66,34 @@ const stripeWebhookProductHandler = (handlers: Handlers) => async (data: Data) = return await handler(data); }; +async function handlePhoneNumberSubscriptionDeleted( + subscription: Data["object"], + phoneNumber: { id: number } +) { + if (!subscription.id) { + throw new HttpCode(400, "Subscription ID not found"); + } + + try { + await prisma.calAiPhoneNumber.update({ + where: { + id: phoneNumber.id, + }, + data: { + subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED, + outboundAgent: { + disconnect: true, + }, + }, + }); + + return { success: true, subscriptionId: subscription.id }; + } catch (error) { + console.error("Failed to update phone number subscription:", error); + throw new HttpCode(500, "Failed to update phone number subscription"); + } +} + export default stripeWebhookProductHandler({ [STRIPE_TEAM_PRODUCT_ID]: () => import("./_customer.subscription.deleted.team-plan"), }); diff --git a/packages/features/ee/billing/api/webhook/_customer.subscription.updated.ts b/packages/features/ee/billing/api/webhook/_customer.subscription.updated.ts new file mode 100644 index 00000000000000..f3c90a62166747 --- /dev/null +++ b/packages/features/ee/billing/api/webhook/_customer.subscription.updated.ts @@ -0,0 +1,65 @@ +import { prisma } from "@calcom/prisma"; +import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/client"; + +import type { SWHMap } from "./__handler"; +import { HttpCode } from "./__handler"; + +type Data = SWHMap["customer.subscription.updated"]["data"]; + +const handler = async (data: Data) => { + const subscription = data.object; + + if (!subscription.id) { + throw new HttpCode(400, "Subscription ID not found"); + } + + // Check if this is a phone number subscription first + const phoneNumber = await prisma.calAiPhoneNumber.findFirst({ + where: { + stripeSubscriptionId: subscription.id, + }, + select: { + id: true, + phoneNumber: true, + }, + }); + + if (!phoneNumber) { + throw new HttpCode(202, "Phone number not found"); + } + + return await handlePhoneNumberSubscriptionUpdate(subscription, phoneNumber); +}; + +type Subscription = Data["object"]; + +async function handlePhoneNumberSubscriptionUpdate( + subscription: Subscription, + phoneNumber: { id: number; phoneNumber: string } +) { + // Map Stripe subscription status to our enum + const statusMap: Record = { + active: PhoneNumberSubscriptionStatus.ACTIVE, + past_due: PhoneNumberSubscriptionStatus.PAST_DUE, + cancelled: PhoneNumberSubscriptionStatus.CANCELLED, + incomplete: PhoneNumberSubscriptionStatus.INCOMPLETE, + incomplete_expired: PhoneNumberSubscriptionStatus.INCOMPLETE_EXPIRED, + trialing: PhoneNumberSubscriptionStatus.TRIALING, + unpaid: PhoneNumberSubscriptionStatus.UNPAID, + }; + + const subscriptionStatus = statusMap[subscription.status] || PhoneNumberSubscriptionStatus.UNPAID; + + await prisma.calAiPhoneNumber.update({ + where: { + id: phoneNumber.id, + }, + data: { + subscriptionStatus, + }, + }); + + return { success: true, subscriptionId: subscription.id, status: subscriptionStatus }; +} + +export default handler; diff --git a/packages/features/ee/billing/api/webhook/index.ts b/packages/features/ee/billing/api/webhook/index.ts index 608f031430eb40..44729974614fc8 100644 --- a/packages/features/ee/billing/api/webhook/index.ts +++ b/packages/features/ee/billing/api/webhook/index.ts @@ -7,6 +7,7 @@ import { stripeWebhookHandler } from "./__handler"; const handlers = { "payment_intent.succeeded": () => import("./_payment_intent.succeeded"), "customer.subscription.deleted": () => import("./_customer.subscription.deleted"), + "customer.subscription.updated": () => import("./_customer.subscription.updated"), "invoice.paid": () => import("./_invoice.paid"), "checkout.session.completed": () => import("./_checkout.session.completed"), }; diff --git a/packages/features/ee/cal-ai-phone/README.md b/packages/features/ee/cal-ai-phone/README.md new file mode 100644 index 00000000000000..59262499b57b2b --- /dev/null +++ b/packages/features/ee/cal-ai-phone/README.md @@ -0,0 +1,764 @@ +# Cal.com AI Phone Service Architecture + +This package provides a comprehensive, provider-agnostic architecture for AI phone services in Cal.com, offering easy integration with different AI service providers, template management, and self-service UI components. + +## Architecture Overview + +The architecture implements multiple design patterns to create a maintainable, scalable, and flexible system: + +### Layered Architecture + +``` +┌─────────────────────────────────────────────┐ +│ Frontend (React/Next.js) │ +│ - UI Components (Agent/Phone Management) │ +├─────────────────────────────────────────────┤ +│ TRPC API Layer │ +│ - Type-safe RPC endpoints │ +│ - Request/Response validation │ +├─────────────────────────────────────────────┤ +│ Service Layer │ +│ - Business logic orchestration │ +│ - Provider abstraction │ +├─────────────────────────────────────────────┤ +│ Repository Layer │ +│ - Data access abstraction │ +│ - Query optimization │ +├─────────────────────────────────────────────┤ +│ Prisma ORM / Database │ +│ - PostgreSQL with optimized queries │ +└─────────────────────────────────────────────┘ +``` + +### Design Patterns Implemented + +1. **Repository Pattern**: Encapsulates data access logic +2. **Factory Pattern**: Creates provider instances dynamically +3. **Strategy Pattern**: Allows switching between AI providers +4. **Service Pattern**: Orchestrates business logic +5. **Registry Pattern**: Manages provider registration +6. **Template Pattern**: Provides reusable conversation templates +7. **Mapper Pattern**: Transforms data between layers + +## Key Components + +### 1. Generic Interfaces (`interfaces/ai-phone-service.interface.ts`) + +- `AIPhoneServiceProvider` - Main interface that all providers must implement +- `AIPhoneServiceConfiguration` - Configuration for setting up AI services +- `AIPhoneServiceProviderFactory` - Factory interface for creating providers +- Common data types (`AIPhoneServiceModel`, `AIPhoneServiceCall`, `AIPhoneServiceAgent`, etc.) +- Phone number management interfaces +- Agent and LLM management interfaces + +### 2. Provider Registry (`ai-phone-service-registry.ts`) + +- `AIPhoneServiceRegistry` - Central registry for managing providers +- `createAIPhoneServiceProvider()` - Helper function to create providers +- `createDefaultAIPhoneServiceProvider()` - Convenience function for default provider +- Provider registration and management system + +### 3. Template System + +- **Template Types**: `CHECK_IN_APPOINTMENT` and `CUSTOM_TEMPLATE` +- **Prompt Templates** (`promptTemplates.ts`): Pre-defined conversation templates with placeholders +- **Field Mapping** (`template-fields-map.ts`): Dynamic field definitions for different template types +- **Schema Validation** (`zod-utils.ts`): Comprehensive validation schemas for all data types +- **Template Field Schema** (`getTemplateFieldsSchema.ts`): Dynamic schema generation based on template type + +### 4. Provider Implementations (`providers/`) + +- `retell-ai/` - Complete Retell AI implementation with: + - Provider class with full interface implementation + - Factory for provider creation + - SDK client for API communication + - Service layer for business logic + - Error handling and type definitions +- `example-future-provider/` - Example showing how to add new providers + +### 5. UI Components (`components/`) + +- `CreateAgentStep.tsx` - Step-by-step agent creation wizard +- `CreateWorkflowAgent.tsx` - Workflow-based agent setup +- `AgentsListPage.tsx` - Agent management and listing +- `SkeletonLoaderList.tsx` - Loading states for better UX + +### 6. Pages (`pages/`) + +- `agent.tsx` - Agent management page +- `index.tsx` - Main AI phone service dashboard + +### 7. Data Layer Components + +- **Repository Classes** (`/packages/lib/server/repository/`) + - `AgentRepository` - Manages agent data access + - `PhoneNumberRepository` - Handles phone number operations +- **Service Classes** (`providers/retell-ai/service.ts`) + - Business logic orchestration + - External API integration +- **Mapper Functions** + - Transform between database models and DTOs + - Handle data serialization/deserialization + +## Architecture Patterns in Detail + +### Repository Pattern + +The repository pattern provides an abstraction layer over data access, making the system database-agnostic and testable. + +```typescript +// AgentRepository example +export class AgentRepository { + // Encapsulates complex queries with access control + static async findManyWithUserAccess({ + userId, + teamId, + scope = "all" + }: { + userId: number; + teamId?: number; + scope?: "personal" | "team" | "all"; + }) { + // Pre-fetch accessible teams for performance + const accessibleTeamIds = await this.getUserAccessibleTeamIds(userId); + + // Build optimized query based on scope + // Returns agents with proper access control + } + + // Single responsibility: data access only + static async create(data: CreateAgentData) { + return await prisma.agent.create({ data }); + } +} +``` + +**Benefits:** +- **Separation of Concerns**: Business logic separated from data access +- **Testability**: Easy to mock for unit tests +- **Flexibility**: Can switch between Prisma ORM and raw SQL +- **Reusability**: Common queries centralized in one place + +### Factory Pattern + +The factory pattern enables dynamic creation of provider instances without exposing instantiation logic. + +```typescript +// Provider Factory Interface +export interface AIPhoneServiceProviderFactory { + create(config: AIPhoneServiceProviderConfig): AIPhoneServiceProvider; +} + +// Concrete Factory Implementation +export class RetellAIProviderFactory implements AIPhoneServiceProviderFactory { + create(config: AIPhoneServiceProviderConfig): AIPhoneServiceProvider { + // Validates configuration + // Creates appropriate provider instance + // Handles initialization logic + return new RetellAIProvider(config); + } +} + +// Usage through registry +const provider = AIPhoneServiceRegistry.createProvider("retell-ai", config); +``` + +**Benefits:** +- **Loose Coupling**: Client code doesn't depend on concrete classes +- **Extensibility**: Easy to add new providers +- **Configuration Management**: Centralized provider setup + +### Service Pattern + +Service layer orchestrates business operations and coordinates between multiple repositories and external services. + +```typescript +// AI Phone Service coordinating multiple operations +export class AIPhoneService { + async setupAgentWithPhoneNumber(config: SetupConfig) { + // 1. Create AI agent via provider + const agent = await this.provider.createAgent(config.agentConfig); + + // 2. Store in database via repository + const savedAgent = await AgentRepository.create({ + ...agent, + userId: config.userId + }); + + // 3. Provision phone number + const phoneNumber = await this.provider.createPhoneNumber({ + areaCode: config.areaCode + }); + + // 4. Link phone number to agent + await PhoneNumberRepository.updateAgents({ + id: phoneNumber.id, + outboundRetellAgentId: savedAgent.retellAgentId + }); + + // 5. Return complete setup + return { agent: savedAgent, phoneNumber }; + } +} +``` + +**Benefits:** +- **Business Logic Encapsulation**: Complex operations in one place +- **Transaction Management**: Coordinates multiple operations +- **Reusability**: Common workflows available to all consumers + +### Registry Pattern + +The registry pattern manages provider registration and lookup, acting as a service locator. + +```typescript +export class AIPhoneServiceRegistry { + private static providers = new Map(); + private static defaultProvider: string = "retell-ai"; + + static registerProvider(name: string, factory: AIPhoneServiceProviderFactory) { + this.providers.set(name, factory); + } + + static createProvider(name: string, config: any): AIPhoneServiceProvider { + const factory = this.providers.get(name); + if (!factory) { + throw new Error(`Provider ${name} not found`); + } + return factory.create(config); + } + + static getAvailableProviders(): string[] { + return Array.from(this.providers.keys()); + } +} +``` + +**Benefits:** +- **Dynamic Provider Management**: Add/remove providers at runtime +- **Centralized Configuration**: Single point for provider management +- **Dependency Injection**: Supports IoC principles + +### Mapper Pattern + +Mappers transform data between different representations (database models, DTOs, API responses). + +```typescript +// Transform raw database result to domain model +export class AgentMapper { + static toDomain(dbAgent: PrismaAgent): DomainAgent { + return { + id: dbAgent.id, + name: dbAgent.name, + retellAgentId: dbAgent.retellAgentId, + // Transform nested relations + phoneNumbers: dbAgent.phoneNumbers?.map(PhoneNumberMapper.toDomain) || [], + // Add computed properties + isActive: dbAgent.enabled && dbAgent.subscriptionStatus === 'ACTIVE' + }; + } + + static toDTO(domain: DomainAgent): AgentDTO { + return { + id: domain.id, + name: domain.name, + // Flatten for API response + phoneNumberCount: domain.phoneNumbers.length, + status: domain.isActive ? 'active' : 'inactive' + }; + } +} +``` + +**Benefits:** +- **Data Transformation**: Clean separation between layers +- **Flexibility**: Different representations for different contexts +- **Maintainability**: Changes in one layer don't affect others + +### Strategy Pattern + +The strategy pattern allows switching between different AI providers seamlessly. + +```typescript +// Common interface for all providers +export interface AIPhoneServiceProvider { + setupConfiguration(config: AIPhoneServiceConfiguration): Promise; + createPhoneCall(data: CallData): Promise; + createAgent(data: AgentData): Promise; + // ... other common operations +} + +// Different provider implementations +export class RetellAIProvider implements AIPhoneServiceProvider { + async createPhoneCall(data: CallData): Promise { + // Retell-specific implementation + return await this.retellClient.createCall(data); + } +} + +export class TwilioAIProvider implements AIPhoneServiceProvider { + async createPhoneCall(data: CallData): Promise { + // Twilio-specific implementation + return await this.twilioClient.calls.create(data); + } +} +``` + +**Benefits:** +- **Provider Independence**: Switch providers without changing client code +- **Extensibility**: Add new providers easily +- **Testing**: Mock providers for testing + +## Integration with Cal.com Workflows + +The AI Phone system is designed specifically for Cal.com's workflow automation, enabling AI-powered phone calls as part of scheduling workflows. + +### Workflow Integration Architecture + +``` +┌─────────────────────────────────────────────┐ +│ Cal.com Workflow Engine │ +├─────────────────────────────────────────────┤ +│ Workflow Step: AI Phone Call │ +├─────────────────────────────────────────────┤ +│ AI Phone Service (via Registry) │ +├─────────────────────────────────────────────┤ +│ Agent + Phone Number Setup │ +└─────────────────────────────────────────────┘ +``` + +### Workflow Step Configuration + +AI Phone calls are configured as workflow steps with specific triggers: + +```typescript +// Workflow Step Definition +export interface AIPhoneWorkflowStep { + id: number; + stepNumber: number; + action: WorkflowActions.AI_PHONE_CALL; + template: "CHECK_IN_APPOINTMENT" | "CUSTOM_TEMPLATE"; + agentId: string; // AI agent to handle the call + sendTo: string; // Phone number to call + trigger: WorkflowTriggerEvents; // When to make the call + time: number; // Minutes before/after event + timeUnit: TimeUnit; +} +``` + +### Supported Workflow Triggers + +AI Phone calls can be triggered by various workflow events: + +- **BEFORE_EVENT**: Call attendees before appointments +- **AFTER_EVENT**: Follow up after meetings +- **NEW_EVENT**: Welcome calls for new bookings +- **RESCHEDULE_EVENT**: Notify about schedule changes +- **CANCELLED_EVENT**: Handle cancellation calls + +### Example: Appointment Reminder Workflow + +```typescript +// 1. Create an AI Agent for appointment reminders +const agent = await AgentRepository.create({ + name: "Appointment Reminder Agent", + templateType: "CHECK_IN_APPOINTMENT", + userId: user.id, + // Agent configured with appointment check-in template +}); + +// 2. Link Agent to Workflow Step +await prisma.workflowStep.create({ + data: { + workflowId: workflow.id, + action: WorkflowActions.AI_PHONE_CALL, + stepNumber: 1, + agentId: agent.id, + template: "CHECK_IN_APPOINTMENT", + trigger: WorkflowTriggerEvents.BEFORE_EVENT, + time: 24, // 24 hours before + timeUnit: TimeUnit.HOUR, + } +}); + +// 3. When workflow triggers, system executes AI phone call +export async function executeAIPhoneCall(workflowStep: WorkflowStep, booking: Booking) { + const aiService = createDefaultAIPhoneServiceProvider(); + + // Get phone number from booking attendee + const phoneNumber = booking.attendees[0]?.phoneNumber; + + // Dynamic variables for the call + const dynamicVariables = { + guestName: booking.attendees[0]?.name, + eventName: booking.eventType?.title, + eventDate: booking.startTime.toISOString(), + schedulerName: booking.user?.name, + }; + + // Execute the AI phone call + const call = await aiService.createPhoneCall({ + from_number: workflowStep.agent.phoneNumber, + to_number: phoneNumber, + retell_llm_dynamic_variables: dynamicVariables, + }); + + // Log call for workflow tracking + await prisma.workflowReminder.update({ + where: { id: workflowReminder.id }, + data: { referenceId: call.call_id } + }); +} +``` + +### Workflow Execution Flow + +``` +1. Workflow Trigger (e.g., 24 hours before event) + ↓ +2. Workflow Engine schedules AI Phone Call task + ↓ +3. Task Runner (executeAIPhoneCall) executes + ↓ +4. AI Phone Service creates call with dynamic data + ↓ +5. Call connects and AI handles conversation + ↓ +6. Call results logged back to workflow +``` + +### Rate Limiting for Workflows + +To prevent abuse, workflow AI calls are rate-limited: + +```typescript +// Rate limiting check in workflow execution +await checkRateLimitAndThrowError({ + rateLimitingType: "core", + identifier: `ai-phone-call:${userId}`, +}); +``` + +## Data Flow Architecture + +### Workflow-Specific Data Flow + +``` +1. Workflow Trigger Event + ↓ +2. Workflow Step Configuration Retrieved + ↓ +3. Booking & Attendee Data Fetched + ↓ +4. Dynamic Variables Prepared + ↓ +5. AI Agent & Phone Number Selected + ↓ +6. Phone Call Initiated + ↓ +7. Results Logged to Workflow +``` + +## Usage Examples + +### Using the Default Provider (Recommended) + +```typescript +import { createDefaultAIPhoneServiceProvider } from "@calcom/features/ee/cal-ai-phone"; + +const aiPhoneService = createDefaultAIPhoneServiceProvider(); + +// Setup AI configuration +const { llmId, agentId } = await aiPhoneService.setupConfiguration({ + generalPrompt: "Your AI assistant prompt...", + beginMessage: "Hi! How can I help you today?", + calApiKey: "cal_live_123...", + eventTypeId: 12345, + loggedInUserTimeZone: "America/New_York", + generalTools: [ + { + type: "check_availability_cal", + name: "check_availability", + cal_api_key: "your-cal-api-key", + event_type_id: 12345, + timezone: "America/New_York" + } + ] +}); + +// Create phone call +const call = await aiPhoneService.createPhoneCall({ + fromNumber: "+1234567890", + toNumber: "+0987654321", + retellLlmDynamicVariables: { + name: "John Doe", + company: "Acme Corp", + email: "john@acme.com" + } +}); +``` + +### Using a Specific Provider + +```typescript +import { + createAIPhoneServiceProvider, + AIPhoneServiceProviderType +} from "@calcom/features/ee/cal-ai-phone"; + +const retellAIService = createAIPhoneServiceProvider( + AIPhoneServiceProviderType.RETELL_AI, + { + apiKey: "your-retell-ai-key", + enableLogging: true, + } +); + +// Full provider API available +const agent = await retellAIService.getAgent("agent-id"); +const phoneNumber = await retellAIService.createPhoneNumber({ + areaCode: 415, + nickname: "Sales Line" +}); +``` + + +### Phone Number Management + +```typescript +const aiPhoneService = createDefaultAIPhoneServiceProvider(); + +// Create a new phone number +const phoneNumber = await aiPhoneService.createPhoneNumber({ + areaCode: 415, + nickname: "Support Line" +}); + +// Update phone number configuration +const updatedNumber = await aiPhoneService.updatePhoneNumber("+14155551234", { + nickname: "Updated Support Line", + inboundAgentId: "agent-123" +}); + +// Import existing phone number +const importedNumber = await aiPhoneService.importPhoneNumber({ + phoneNumber: "+14155559999", + nickname: "Imported Line", + userId: 123 +}); + +// Delete phone number +await aiPhoneService.deletePhoneNumber({ + phoneNumber: "+14155551234", + userId: 123, + deleteFromDB: true +}); +``` + +### Field Types + +Supported field types include: +- `text`, `textarea`, `number`, `email`, `phone` +- `address`, `multiemail`, `select`, `multiselect` +- `checkbox`, `radio`, `radioInput`, `boolean` + +### Template Variables + +Templates support dynamic variables: +- `{{scheduler_name}}` - Name of the scheduler +- `{{name}}` - Guest name +- `{{email}}` - Guest email +- `{{company}}` - Guest company +- `{{current_time}}` - Current timestamp +- And many more + +## Adding New Providers + +Adding a new AI service provider follows the established pattern: + +### 1. Create Provider Implementation + +```typescript +// providers/new-provider/provider.ts +import type { AIPhoneServiceProvider } from "../../interfaces/ai-phone-service.interface"; + +export class NewAIProvider implements AIPhoneServiceProvider { + constructor(private config: AIPhoneServiceProviderConfig) {} + + async setupConfiguration(config: AIPhoneServiceConfiguration) { + // Implementation for setting up LLM and agent + return { llmId: "new-llm-id", agentId: "new-agent-id" }; + } + + async createPhoneCall(data: AIPhoneServiceCallData) { + // Implementation for creating phone calls + return { callId: "new-call-id", /* other properties */ }; + } + + async createPhoneNumber(data: AIPhoneServiceCreatePhoneNumberParams) { + // Implementation for phone number management + } + + // ... implement all required methods +} +``` + +### 2. Create Factory + +```typescript +// providers/new-provider/factory.ts +import type { AIPhoneServiceProviderFactory } from "../../interfaces/ai-phone-service.interface"; + +export class NewAIProviderFactory implements AIPhoneServiceProviderFactory { + create(config: AIPhoneServiceProviderConfig): AIPhoneServiceProvider { + return new NewAIProvider(config); + } +} +``` + +### 3. Register Provider + +```typescript +import { AIPhoneServiceRegistry } from "@calcom/features/ee/cal-ai-phone"; +import { NewAIProviderFactory } from "./providers/new-provider"; + +AIPhoneServiceRegistry.registerProvider( + "new-provider", + new NewAIProviderFactory() +); +``` + +### 4. Update Provider Types + +```typescript +// interfaces/ai-phone-service.interface.ts +export enum AIPhoneServiceProviderType { + RETELL_AI = "retell-ai", + NEW_PROVIDER = "new-provider", // Add new provider +} +``` + + + +## Environment Variables + +- `RETELL_AI_KEY` - API key for Retell AI (required when using Retell AI provider) +- `STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID` - Stripe price ID for monthly phone number subscriptions (required for billing) +- `NODE_ENV` - Controls logging behavior (logging disabled in production) + +### Stripe Integration + +The AI Phone system integrates with Stripe for phone number billing. When users purchase phone numbers through the workflow system, they're redirected to Stripe checkout: + +```typescript +// Phone number purchase flow +const checkoutSession = await aiService.generatePhoneNumberCheckoutSession({ + userId, + teamId: input?.teamId, + agentId: input?.agentId, + workflowId: input?.workflowId, +}); + +// Returns Stripe checkout URL +return { + checkoutUrl: checkoutSession.url, + message: checkoutSession.message, +}; +``` + +The `STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID` should be set to your Stripe price ID that represents the monthly subscription cost for AI phone numbers. This price ID is created in your Stripe dashboard and determines how much customers are charged monthly for each phone number. + +## Credit System and Billing + +### Webhook-Based Credit Deduction + +The AI Phone system automatically deducts credits from user or team accounts when calls are completed. This is handled by the webhook at `apps/web/app/api/webhooks/retell-ai/route.ts`. + +#### Credit Deduction Flow + +``` +1. AI Phone Call Completes + ↓ +2. Provider sends "call_analyzed" webhook + ↓ +3. Webhook verifies signature for security + ↓ +4. System looks up phone number owner (user/team) + ↓ +5. Calculates credits based on usage factors + ↓ +6. Verifies sufficient credits available + ↓ +7. Deducts credits from account + ↓ +8. Logs transaction for audit +``` + +#### Credit Calculation + +Credits are deducted based on multiple factors including: + +- **Call Duration**: Longer calls consume more credits +- **Prompt Complexity**: Advanced prompts and conversation templates affect cost +- **Voice Configuration**: Different voice models and settings impact pricing +- **Features Used**: Additional AI capabilities and tools increase credit usage + +The system calculates the total cost and converts it to credits for deduction from the user or team account. + +#### Webhook Setup + +The webhook requires proper configuration: + +1. **Webhook URL**: Add the webhook endpoint to your AI provider dashboard +2. **Environment Variables**: Set appropriate API keys for webhook verification +3. **Security**: Webhook signatures are verified to ensure authenticity + +#### Credit Service Integration + +The webhook integrates with Cal.com's credit system: + +```typescript +const creditService = new CreditService(); + +// Check if user/team has sufficient credits +const hasCredits = await creditService.hasAvailableCredits({ userId, teamId }); + +// Deduct credits after call completion +await creditService.chargeCredits({ + userId: userId ?? undefined, + teamId: teamId ?? undefined, + credits: creditsToDeduct, +}); +``` + +#### Error Handling + +The webhook includes comprehensive error handling: + +- **Invalid Signatures**: Returns 401 Unauthorized +- **Missing Phone Number**: Logs error, cannot deduct credits +- **Insufficient Credits**: Logs warning but doesn't block (call already completed) +- **Missing Cost Data**: Logs error and skips credit deduction + +#### Supported Events + +The webhook processes call completion events that contain the final usage data and cost information. Other events are acknowledged but not processed for billing purposes. + +## Provider-Specific Documentation + +- [Retell AI Provider](./providers/retell-ai/README.md) - Complete Retell AI integration +- [Example Provider](./providers/example-future-provider/README.md) - Template for new providers + + +## Future Enhancements + +- Multi-language template support +- Advanced workflow builder UI +- Provider health checks and automatic failover +- Real-time call monitoring and analytics +- Integration with Cal.com's webhook system +- Custom field type plugins +- Template marketplace and sharing +- Advanced agent training and optimization tools +- Integration with more AI service providers +- Voice cloning and customization options diff --git a/packages/features/ee/cal-ai-phone/ai-phone-service-registry.test.ts b/packages/features/ee/cal-ai-phone/ai-phone-service-registry.test.ts new file mode 100644 index 00000000000000..f1910123987ce7 --- /dev/null +++ b/packages/features/ee/cal-ai-phone/ai-phone-service-registry.test.ts @@ -0,0 +1,359 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +import { + AIPhoneServiceRegistry, + createAIPhoneServiceProvider, + createDefaultAIPhoneServiceProvider, +} from "./ai-phone-service-registry"; +import type { + AIPhoneServiceProvider, + AIPhoneServiceProviderFactory, + AIPhoneServiceProviderConfig, +} from "./interfaces/ai-phone-service.interface"; +import { AIPhoneServiceProviderType } from "./interfaces/ai-phone-service.interface"; + +vi.mock("@calcom/lib/constants", () => ({ + RETELL_API_KEY: "test-api-key", +})); + +vi.mock("./providers/retell-ai", () => ({ + RetellAIProviderFactory: vi.fn().mockImplementation(() => ({ + create: vi.fn().mockReturnValue({ + setupConfiguration: vi.fn(), + createPhoneCall: vi.fn(), + createPhoneNumber: vi.fn(), + }), + })), +})); + +describe("AIPhoneServiceRegistry", () => { + let mockFactory: AIPhoneServiceProviderFactory; + let mockProvider: AIPhoneServiceProvider; + + beforeEach(() => { + // Clear all factories before each test + AIPhoneServiceRegistry.clearProviders(); + + // Create mock factory and provider + mockProvider = { + setupConfiguration: vi.fn().mockResolvedValue({ llmId: "test-llm", agentId: "test-agent" }), + deleteConfiguration: vi + .fn() + .mockResolvedValue({ success: true, errors: [], deleted: { llm: true, agent: true } }), + updateModelConfiguration: vi.fn().mockResolvedValue({ llm_id: "test-llm" }), + getModelDetails: vi.fn().mockResolvedValue({ llm_id: "test-llm" }), + getAgent: vi.fn().mockResolvedValue({ agent_id: "test-agent" }), + updateAgent: vi.fn().mockResolvedValue({ agent_id: "test-agent" }), + createPhoneCall: vi.fn().mockResolvedValue({ call_id: "test-call" }), + createPhoneNumber: vi.fn().mockResolvedValue({ phone_number: "+1234567890" }), + deletePhoneNumber: vi.fn().mockResolvedValue(undefined), + getPhoneNumber: vi.fn().mockResolvedValue({ phone_number: "+1234567890" }), + updatePhoneNumber: vi.fn().mockResolvedValue({ phone_number: "+1234567890" }), + importPhoneNumber: vi.fn().mockResolvedValue({ phone_number: "+1234567890" }), + generatePhoneNumberCheckoutSession: vi + .fn() + .mockResolvedValue({ url: "test-url", message: "test-message" }), + cancelPhoneNumberSubscription: vi.fn().mockResolvedValue({ success: true, message: "test-message" }), + updatePhoneNumberWithAgents: vi.fn().mockResolvedValue({ message: "test-message" }), + listAgents: vi.fn().mockResolvedValue({ totalCount: 1, filtered: [] }), + getAgentWithDetails: vi.fn().mockResolvedValue({ agent_id: "test-agent" }), + createAgent: vi + .fn() + .mockResolvedValue({ id: "test-id", providerAgentId: "test-provider-id", message: "test-message" }), + updateAgentConfiguration: vi.fn().mockResolvedValue({ message: "test-message" }), + deleteAgent: vi.fn().mockResolvedValue({ message: "test-message" }), + createTestCall: vi + .fn() + .mockResolvedValue({ callId: "test-call-id", status: "test-status", message: "test-message" }), + }; + + mockFactory = { + create: vi.fn().mockReturnValue(mockProvider), + }; + }); + + describe("registerProvider", () => { + it("should register a provider factory", () => { + AIPhoneServiceRegistry.registerProvider("test-provider", mockFactory); + + expect(AIPhoneServiceRegistry.isProviderRegistered("test-provider")).toBe(true); + expect(AIPhoneServiceRegistry.getAvailableProviders()).toContain("test-provider"); + }); + + it("should allow overriding existing provider", () => { + const newMockFactory: AIPhoneServiceProviderFactory = { + create: vi.fn().mockReturnValue(mockProvider), + }; + + AIPhoneServiceRegistry.registerProvider("test-provider", mockFactory); + AIPhoneServiceRegistry.registerProvider("test-provider", newMockFactory); + + const retrievedFactory = AIPhoneServiceRegistry.getProviderFactory("test-provider"); + expect(retrievedFactory).toBe(newMockFactory); + }); + }); + + describe("getProviderFactory", () => { + it("should return registered factory", () => { + AIPhoneServiceRegistry.registerProvider("test-provider", mockFactory); + + const retrievedFactory = AIPhoneServiceRegistry.getProviderFactory("test-provider"); + expect(retrievedFactory).toBe(mockFactory); + }); + + it("should return undefined for unregistered provider", () => { + const retrievedFactory = AIPhoneServiceRegistry.getProviderFactory("nonexistent"); + expect(retrievedFactory).toBeUndefined(); + }); + }); + + describe("createProvider", () => { + it("should create provider instance using registered factory", () => { + const config: AIPhoneServiceProviderConfig = { apiKey: "test-key" }; + AIPhoneServiceRegistry.registerProvider("test-provider", mockFactory); + + const provider = AIPhoneServiceRegistry.createProvider("test-provider", config); + + expect(mockFactory.create).toHaveBeenCalledWith(config); + expect(provider).toBe(mockProvider); + }); + + it("should throw error for unregistered provider", () => { + const config: AIPhoneServiceProviderConfig = { apiKey: "test-key" }; + + expect(() => { + AIPhoneServiceRegistry.createProvider("nonexistent", config); + }).toThrow("AI phone service provider 'nonexistent' not found. Available providers: "); + }); + + it("should include available providers in error message when factory not found", () => { + AIPhoneServiceRegistry.registerProvider("provider1", mockFactory); + AIPhoneServiceRegistry.registerProvider("provider2", mockFactory); + + expect(() => { + AIPhoneServiceRegistry.createProvider("nonexistent", {}); + }).toThrow("Available providers: provider1, provider2"); + }); + }); + + describe("createDefaultProvider", () => { + it("should create provider using default provider type", () => { + const config: AIPhoneServiceProviderConfig = { apiKey: "test-key" }; + AIPhoneServiceRegistry.registerProvider(AIPhoneServiceProviderType.RETELL_AI, mockFactory); + + const provider = AIPhoneServiceRegistry.createDefaultProvider(config); + + expect(mockFactory.create).toHaveBeenCalledWith(config); + expect(provider).toBe(mockProvider); + }); + }); + + describe("setDefaultProvider", () => { + it("should set default provider when provider is registered", () => { + AIPhoneServiceRegistry.registerProvider("new-default", mockFactory); + + AIPhoneServiceRegistry.setDefaultProvider("new-default"); + + expect(AIPhoneServiceRegistry.getDefaultProvider()).toBe("new-default"); + }); + + it("should throw error when setting unregistered provider as default", () => { + expect(() => { + AIPhoneServiceRegistry.setDefaultProvider("nonexistent"); + }).toThrow("Cannot set default provider to 'nonexistent' - provider not registered"); + }); + }); + + describe("getDefaultProvider", () => { + it("should return current default provider", () => { + // Reset to default provider before testing + AIPhoneServiceRegistry.registerProvider(AIPhoneServiceProviderType.RETELL_AI, mockFactory); + (AIPhoneServiceRegistry as any).defaultProvider = AIPhoneServiceProviderType.RETELL_AI; + + expect(AIPhoneServiceRegistry.getDefaultProvider()).toBe(AIPhoneServiceProviderType.RETELL_AI); + }); + }); + + describe("getAvailableProviders", () => { + it("should return empty array when no providers registered", () => { + expect(AIPhoneServiceRegistry.getAvailableProviders()).toEqual([]); + }); + + it("should return array of registered provider names", () => { + AIPhoneServiceRegistry.registerProvider("provider1", mockFactory); + AIPhoneServiceRegistry.registerProvider("provider2", mockFactory); + + const providers = AIPhoneServiceRegistry.getAvailableProviders(); + expect(providers).toContain("provider1"); + expect(providers).toContain("provider2"); + expect(providers).toHaveLength(2); + }); + }); + + describe("isProviderRegistered", () => { + it("should return true for registered provider", () => { + AIPhoneServiceRegistry.registerProvider("test-provider", mockFactory); + + expect(AIPhoneServiceRegistry.isProviderRegistered("test-provider")).toBe(true); + }); + + it("should return false for unregistered provider", () => { + expect(AIPhoneServiceRegistry.isProviderRegistered("nonexistent")).toBe(false); + }); + }); +}); + +describe("createAIPhoneServiceProvider", () => { + let mockFactory: AIPhoneServiceProviderFactory; + let mockProvider: AIPhoneServiceProvider; + + beforeEach(() => { + // Clear all factories before each test + AIPhoneServiceRegistry.clearProviders(); + + mockProvider = { + setupConfiguration: vi.fn(), + deleteConfiguration: vi.fn(), + updateModelConfiguration: vi.fn(), + getModelDetails: vi.fn(), + getAgent: vi.fn(), + updateAgent: vi.fn(), + createPhoneCall: vi.fn(), + createPhoneNumber: vi.fn(), + deletePhoneNumber: vi.fn(), + getPhoneNumber: vi.fn(), + updatePhoneNumber: vi.fn(), + importPhoneNumber: vi.fn(), + generatePhoneNumberCheckoutSession: vi.fn(), + cancelPhoneNumberSubscription: vi.fn(), + updatePhoneNumberWithAgents: vi.fn(), + listAgents: vi.fn(), + getAgentWithDetails: vi.fn(), + createAgent: vi.fn(), + updateAgentConfiguration: vi.fn(), + deleteAgent: vi.fn(), + createTestCall: vi.fn(), + }; + + mockFactory = { + create: vi.fn().mockReturnValue(mockProvider), + }; + }); + + it("should create provider with specified type and config", () => { + AIPhoneServiceRegistry.registerProvider("custom-provider", mockFactory); + + const customConfig = { apiKey: "custom-key", enableLogging: false }; + const provider = createAIPhoneServiceProvider("custom-provider", customConfig); + + expect(mockFactory.create).toHaveBeenCalledWith({ + apiKey: "custom-key", + enableLogging: false, + }); + expect(provider).toBe(mockProvider); + }); + + it("should use RETELL_AI as default provider type", () => { + AIPhoneServiceRegistry.registerProvider(AIPhoneServiceProviderType.RETELL_AI, mockFactory); + + const provider = createAIPhoneServiceProvider(); + + expect(mockFactory.create).toHaveBeenCalledWith({ + apiKey: "test-api-key", + enableLogging: true, // Should be true when NODE_ENV is not production + }); + expect(provider).toBe(mockProvider); + }); + + it("should merge provided config with defaults", () => { + AIPhoneServiceRegistry.registerProvider(AIPhoneServiceProviderType.RETELL_AI, mockFactory); + + const customConfig = { enableLogging: false }; + createAIPhoneServiceProvider(undefined, customConfig); + + expect(mockFactory.create).toHaveBeenCalledWith({ + apiKey: "test-api-key", + enableLogging: false, + }); + }); + + it("should override default apiKey with provided config", () => { + AIPhoneServiceRegistry.registerProvider(AIPhoneServiceProviderType.RETELL_AI, mockFactory); + + const customConfig = { apiKey: "override-key" }; + createAIPhoneServiceProvider(undefined, customConfig); + + expect(mockFactory.create).toHaveBeenCalledWith({ + apiKey: "override-key", + enableLogging: true, + }); + }); +}); + +describe("createDefaultAIPhoneServiceProvider", () => { + let mockFactory: AIPhoneServiceProviderFactory; + let mockProvider: AIPhoneServiceProvider; + + beforeEach(() => { + AIPhoneServiceRegistry.clearProviders(); + + mockProvider = { + setupConfiguration: vi.fn(), + deleteConfiguration: vi.fn(), + updateModelConfiguration: vi.fn(), + getModelDetails: vi.fn(), + getAgent: vi.fn(), + updateAgent: vi.fn(), + createPhoneCall: vi.fn(), + createPhoneNumber: vi.fn(), + deletePhoneNumber: vi.fn(), + getPhoneNumber: vi.fn(), + updatePhoneNumber: vi.fn(), + importPhoneNumber: vi.fn(), + generatePhoneNumberCheckoutSession: vi.fn(), + cancelPhoneNumberSubscription: vi.fn(), + updatePhoneNumberWithAgents: vi.fn(), + listAgents: vi.fn(), + getAgentWithDetails: vi.fn(), + createAgent: vi.fn(), + updateAgentConfiguration: vi.fn(), + deleteAgent: vi.fn(), + createTestCall: vi.fn(), + }; + + mockFactory = { + create: vi.fn().mockReturnValue(mockProvider), + }; + + AIPhoneServiceRegistry.registerProvider(AIPhoneServiceProviderType.RETELL_AI, mockFactory); + }); + + it("should create provider using default configuration", () => { + const provider = createDefaultAIPhoneServiceProvider(); + + expect(mockFactory.create).toHaveBeenCalledWith({ + apiKey: "test-api-key", + enableLogging: true, + }); + expect(provider).toBe(mockProvider); + }); + + it("should merge provided config with defaults", () => { + const customConfig = { enableLogging: false, apiKey: "custom-key" }; + const provider = createDefaultAIPhoneServiceProvider(customConfig); + + expect(mockFactory.create).toHaveBeenCalledWith({ + apiKey: "custom-key", + enableLogging: false, + }); + expect(provider).toBe(mockProvider); + }); + + it("should use default provider type internally", () => { + // This test ensures createDefaultAIPhoneServiceProvider calls createAIPhoneServiceProvider correctly + const provider = createDefaultAIPhoneServiceProvider(); + + expect(provider).toBe(mockProvider); + expect(mockFactory.create).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/features/ee/cal-ai-phone/ai-phone-service-registry.ts b/packages/features/ee/cal-ai-phone/ai-phone-service-registry.ts new file mode 100644 index 00000000000000..06252294428ad5 --- /dev/null +++ b/packages/features/ee/cal-ai-phone/ai-phone-service-registry.ts @@ -0,0 +1,94 @@ +import { RETELL_API_KEY } from "@calcom/lib/constants"; + +import { AIPhoneServiceProviderType } from "./interfaces/ai-phone-service.interface"; +import type { + AIPhoneServiceProvider, + AIPhoneServiceProviderFactory, + AIPhoneServiceProviderConfig, +} from "./interfaces/ai-phone-service.interface"; +import { RetellAIProviderFactory } from "./providers/retell-ai"; + +/** + * Registry for AI phone service providers + * Allows registering and creating different AI service providers + */ +export class AIPhoneServiceRegistry { + private static factories: Map = new Map(); + private static defaultProvider: string = AIPhoneServiceProviderType.RETELL_AI; + + static registerProvider(type: string, factory: AIPhoneServiceProviderFactory): void { + this.factories.set(type, factory); + } + + static getProviderFactory(type: string): AIPhoneServiceProviderFactory | undefined { + return this.factories.get(type); + } + + static createProvider(type: string, config: AIPhoneServiceProviderConfig): AIPhoneServiceProvider { + const factory = this.getProviderFactory(type); + if (!factory) { + throw new Error( + `AI phone service provider '${type}' not found. Available providers: ${Array.from( + this.factories.keys() + ).join(", ")}` + ); + } + return factory.create(config); + } + + /** + * Create a provider instance using the default provider + */ + static createDefaultProvider(config: AIPhoneServiceProviderConfig): AIPhoneServiceProvider { + return this.createProvider(AIPhoneServiceRegistry.defaultProvider, config); + } + + static setDefaultProvider(type: string): void { + if (!this.factories.has(type)) { + throw new Error(`Cannot set default provider to '${type}' - provider not registered`); + } + this.defaultProvider = type; + } + + static getDefaultProvider(): string { + return AIPhoneServiceRegistry.defaultProvider; + } + + static getAvailableProviders(): string[] { + return Array.from(this.factories.keys()); + } + + static isProviderRegistered(type: string): boolean { + return this.factories.has(type); + } + + /** + * Clear all registered providers (mainly for testing purposes) + */ + static clearProviders(): void { + this.factories.clear(); + } +} + +AIPhoneServiceRegistry.registerProvider(AIPhoneServiceProviderType.RETELL_AI, new RetellAIProviderFactory()); + +export function createAIPhoneServiceProvider( + providerType?: string, + config?: Partial +): AIPhoneServiceProvider { + const type = providerType || AIPhoneServiceProviderType.RETELL_AI; + + const providerConfig: AIPhoneServiceProviderConfig = { + apiKey: RETELL_API_KEY || "", + enableLogging: process.env.NODE_ENV !== "production", + ...config, + }; + + return AIPhoneServiceRegistry.createProvider(type, providerConfig); +} + +export function createDefaultAIPhoneServiceProvider( + config?: Partial +): AIPhoneServiceProvider { + return createAIPhoneServiceProvider(undefined, config); +} diff --git a/packages/features/ee/cal-ai-phone/index.ts b/packages/features/ee/cal-ai-phone/index.ts new file mode 100644 index 00000000000000..1c69d7b00716c2 --- /dev/null +++ b/packages/features/ee/cal-ai-phone/index.ts @@ -0,0 +1,92 @@ +// Generic AI Phone Service Interfaces +export type { + AIPhoneServiceProvider, + AIPhoneServiceProviderFactory, + AIPhoneServiceProviderConfig, + AIPhoneServiceConfiguration, + AIPhoneServiceDeletion, + AIPhoneServiceDeletionResult, + AIPhoneServiceCallData, + AIPhoneServiceModel, + AIPhoneServiceAgent, + AIPhoneServiceCall, + AIPhoneServicePhoneNumber, +} from "./interfaces/ai-phone-service.interface"; + +export { AIPhoneServiceProviderType } from "./interfaces/ai-phone-service.interface"; + +// Registry System +export { + AIPhoneServiceRegistry, + createAIPhoneServiceProvider, + createDefaultAIPhoneServiceProvider, +} from "./ai-phone-service-registry"; + +// Provider Implementations +export { + RetellAIProvider, + RetellAIProviderFactory, + RetellAIService, + RetellSDKClient, + RetellAIError, +} from "./providers/retell-ai"; + +export type { + RetellAIRepository, + CreateLLMRequest, + CreateAgentRequest, + UpdateLLMRequest, + AIConfigurationSetup, + AIConfigurationDeletion, + DeletionResult, +} from "./providers/retell-ai"; + +// Legacy exports for backward compatibility +export { RetellAIService as LegacyRetellAIService } from "./retellAIService"; + +// Other exports +export { DEFAULT_PROMPT_VALUE, DEFAULT_BEGIN_MESSAGE, PROMPT_TEMPLATES } from "./promptTemplates"; +export { getTemplateFieldsSchema } from "./getTemplateFieldsSchema"; +export { templateFieldsMap } from "./template-fields-map"; + +// Re-export zod schemas +export * from "./zod-utils"; + +// ===== USAGE EXAMPLES ===== +/* +// Recommended usage with provider abstraction +import { createDefaultAIPhoneServiceProvider } from "@calcom/features/ee/cal-ai-phone"; + +const aiPhoneService = createDefaultAIPhoneServiceProvider(); + +// Setup AI configuration +const { modelId, agentId } = await aiPhoneService.setupConfiguration({ + calApiKey: "cal_live_123...", + timeZone: "America/New_York", + eventTypeId: 12345, +}); + +// Create phone call +const call = await aiPhoneService.createPhoneCall({ + fromNumber: "+1234567890", + toNumber: "+0987654321", + dynamicVariables: { + name: "John Doe", + company: "Acme Corp", + email: "john@acme.com", + }, +}); + +// Using a specific provider +import { createAIPhoneServiceProvider, AIPhoneServiceProviderType } from "@calcom/features/ee/cal-ai-phone"; + +const retellAIService = createAIPhoneServiceProvider(AIPhoneServiceProviderType.RETELL_AI, { + apiKey: "your-retell-ai-key", + enableLogging: true, +}); + +// Legacy usage (still supported) +import { RetellAIServiceFactory } from "@calcom/features/ee/cal-ai-phone"; + +const legacyService = RetellAIServiceFactory.create(); +*/ diff --git a/packages/features/ee/cal-ai-phone/interfaces/ai-phone-service.interface.ts b/packages/features/ee/cal-ai-phone/interfaces/ai-phone-service.interface.ts new file mode 100644 index 00000000000000..655e074701f099 --- /dev/null +++ b/packages/features/ee/cal-ai-phone/interfaces/ai-phone-service.interface.ts @@ -0,0 +1,278 @@ +import type { + AIConfigurationSetup as RetellAIConfigurationSetup, + UpdateLLMRequest as RetellAIUpdateModelParams, + RetellLLM as RetellAIModel, + RetellAgent as RetellAIAgent, + RetellCall as RetellAICall, + CreatePhoneCallParams as RetellAICreatePhoneCallParams, + RetellPhoneNumber as RetellAIPhoneNumber, + UpdatePhoneNumberParams as RetellAIUpdatePhoneNumberParams, + CreatePhoneNumberParams as RetellAICreatePhoneNumberParams, + ImportPhoneNumberParams as RetellAIImportPhoneNumberParams, + UpdateAgentRequest as RetellAIUpdateAgentParams, + RetellLLMGeneralTools as RetellAITools, + RetellAgentWithDetails, +} from "../providers/retell-ai/types"; + +export type AIPhoneServiceConfiguration = RetellAIConfigurationSetup; +export type AIPhoneServiceUpdateModelParams = RetellAIUpdateModelParams; +export type AIPhoneServiceModel = RetellAIModel; + +export interface AIPhoneServiceDeletion { + modelId?: string; + agentId?: string; +} + +export interface AIPhoneServiceDeletionResult { + success: boolean; + errors: string[]; + deleted: { + model: boolean; + agent: boolean; + }; +} + +export type AIPhoneServiceCallData = RetellAICreatePhoneCallParams; +export type AIPhoneServiceAgent = RetellAIAgent; +export type AIPhoneServiceCall = RetellAICall; + +export type AIPhoneServicePhoneNumber = RetellAIPhoneNumber; +export type AIPhoneServiceUpdatePhoneNumberParams = RetellAIUpdatePhoneNumberParams; +export type AIPhoneServiceCreatePhoneNumberParams = RetellAICreatePhoneNumberParams; +export type AIPhoneServiceImportPhoneNumberParams = RetellAIImportPhoneNumberParams & { + userId: number; + teamId?: number; + agentId?: string | null; +}; +export type AIPhoneServiceUpdateAgentParams = RetellAIUpdateAgentParams; +export type AIPhoneServiceTools = RetellAITools; + +export interface AIPhoneServiceAgentListItem { + id: string; + name: string; + retellAgentId: string; + enabled: boolean; + userId: number; + teamId: number | null; + createdAt: Date; + updatedAt: Date; + outboundPhoneNumbers: { + id: number; + phoneNumber: string; + subscriptionStatus: string | null; + provider: string | null; + }[]; + team: { + id: number; + name: string | null | undefined; + slug: string | null | undefined; + } | null; + user: { + id: number; + name: string | null | undefined; + email: string | undefined; + } | null; +} +/** + * Generic interface for AI phone service providers + * This interface abstracts away provider-specific details + */ +export interface AIPhoneServiceProvider { + /** + * Setup AI configuration + */ + setupConfiguration(config: AIPhoneServiceConfiguration): Promise<{ + modelId: string; + agentId: string; + }>; + + /** + * Delete AI configuration + */ + deleteConfiguration(config: AIPhoneServiceDeletion): Promise; + + /** + * Update model configuration + */ + updateModelConfiguration( + modelId: string, + data: AIPhoneServiceUpdateModelParams + ): Promise; + + /** + * Get model details + */ + getModelDetails(modelId: string): Promise; + + /** + * Get agent details + */ + getAgent(agentId: string): Promise; + + /** + * Update agent configuration + */ + updateAgent(agentId: string, data: AIPhoneServiceUpdateAgentParams): Promise; + + /** + * Create a phone call + */ + createPhoneCall(data: AIPhoneServiceCallData): Promise; + + /** + * Create a phone number + */ + createPhoneNumber(data: AIPhoneServiceCreatePhoneNumberParams): Promise; + + /** + * Delete a phone number + */ + deletePhoneNumber(params: { + phoneNumber: string; + userId: number; + teamId?: number; + deleteFromDB: boolean; + }): Promise; + + /** + * Get phone number details + */ + getPhoneNumber(phoneNumber: string): Promise; + + /** + * Update phone number configuration + */ + updatePhoneNumber( + phoneNumber: string, + data: AIPhoneServiceUpdatePhoneNumberParams + ): Promise; + + /** + * Import a phone number + */ + importPhoneNumber(data: AIPhoneServiceImportPhoneNumberParams): Promise; + + /** + * Generate a checkout session for phone number subscription + */ + generatePhoneNumberCheckoutSession(params: { + userId: number; + teamId?: number; + agentId?: string | null; + workflowId?: string; + }): Promise<{ url: string; message: string }>; + + /** + * Cancel a phone number subscription + */ + cancelPhoneNumberSubscription(params: { + phoneNumberId: number; + userId: number; + teamId?: number; + }): Promise<{ success: boolean; message: string }>; + + /** + * Update phone number with agent assignments + */ + updatePhoneNumberWithAgents(params: { + phoneNumber: string; + userId: number; + teamId?: number; + inboundAgentId?: string | null; + outboundAgentId?: string | null; + }): Promise<{ message: string }>; + + /** + * List agents with user access + */ + listAgents(params: { userId: number; teamId?: number; scope?: "personal" | "team" | "all" }): Promise<{ + totalCount: number; + filtered: AIPhoneServiceAgentListItem[]; + }>; + + /** + * Get agent with detailed information + */ + getAgentWithDetails(params: { + id: string; + userId: number; + teamId?: number; + }): Promise; + + /** + * Create a new agent + */ + createAgent(params: { + name?: string; + userId: number; + teamId?: number; + workflowStepId?: number; + generalPrompt?: string; + beginMessage?: string; + generalTools?: AIPhoneServiceTools; + voiceId?: string; + userTimeZone: string; + }): Promise<{ + id: string; + providerAgentId: string; + message: string; + }>; + + /** + * Update agent configuration + */ + updateAgentConfiguration(params: { + id: string; + userId: number; + name?: string; + enabled?: boolean; + generalPrompt?: string | null; + beginMessage?: string | null; + generalTools?: AIPhoneServiceTools; + voiceId?: string; + }): Promise<{ message: string }>; + + /** + * Delete an agent + */ + deleteAgent(params: { id: string; userId: number; teamId?: number }): Promise<{ message: string }>; + + /** + * Create a test call + */ + createTestCall(params: { + agentId: string; + phoneNumber?: string; + userId: number; + teamId?: number; + }): Promise<{ + callId: string; + status: string; + message: string; + }>; +} + +/** + * Configuration for AI phone service providers + */ +export interface AIPhoneServiceProviderConfig { + apiKey?: string; + baseUrl?: string; + enableLogging?: boolean; + logger?: any; // Logger instance +} + +/** + * Factory interface for creating AI phone service providers + */ +export interface AIPhoneServiceProviderFactory { + create(config: AIPhoneServiceProviderConfig): AIPhoneServiceProvider; +} + +/** + * Enum for supported AI phone service providers + */ +export enum AIPhoneServiceProviderType { + RETELL_AI = "retell-ai", + // Add other providers here as needed +} diff --git a/packages/features/ee/cal-ai-phone/providers/retell-ai/client.test.ts b/packages/features/ee/cal-ai-phone/providers/retell-ai/client.test.ts new file mode 100644 index 00000000000000..a5cb95cb5f8767 --- /dev/null +++ b/packages/features/ee/cal-ai-phone/providers/retell-ai/client.test.ts @@ -0,0 +1,412 @@ +import { Retell } from "retell-sdk"; +import { describe, it, expect, beforeEach, vi } from "vitest"; + +import logger from "@calcom/lib/logger"; + +import { RetellSDKClient } from "./client"; +import type { + CreateLLMRequest, + UpdateLLMRequest, + CreateAgentRequest, + UpdateAgentRequest, + CreatePhoneNumberParams, + ImportPhoneNumberParams, + RetellDynamicVariables, +} from "./types"; + +vi.mock("retell-sdk", () => ({ + Retell: vi.fn().mockImplementation(() => ({ + llm: { + create: vi.fn(), + retrieve: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + agent: { + create: vi.fn(), + retrieve: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + phoneNumber: { + create: vi.fn(), + import: vi.fn(), + delete: vi.fn(), + retrieve: vi.fn(), + update: vi.fn(), + }, + call: { + createPhoneCall: vi.fn(), + }, + })), +})); + +vi.mock("@calcom/lib/constants", () => ({ + RETELL_API_KEY: "test-retell-api-key", +})); + +vi.mock("@calcom/lib/logger", () => ({ + default: { + getSubLogger: vi.fn().mockReturnValue({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), + }, +})); + +describe("RetellSDKClient", () => { + let client: RetellSDKClient; + let mockRetellInstance: any; + let mockLogger: any; + + beforeEach(() => { + vi.clearAllMocks(); + + mockLogger = { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }; + + mockRetellInstance = { + llm: { + create: vi.fn(), + retrieve: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + agent: { + create: vi.fn(), + retrieve: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + phoneNumber: { + create: vi.fn(), + import: vi.fn(), + delete: vi.fn(), + retrieve: vi.fn(), + update: vi.fn(), + }, + call: { + createPhoneCall: vi.fn(), + }, + }; + + (Retell as any).mockImplementation(() => mockRetellInstance); + }); + + describe("constructor", () => { + it("should create client with default logger when no custom logger provided", () => { + client = new RetellSDKClient(); + + expect(logger.getSubLogger).toHaveBeenCalledWith({ prefix: ["retellSDKClient:"] }); + expect(Retell).toHaveBeenCalledWith({ apiKey: "test-retell-api-key" }); + }); + + it("should create client with custom logger", () => { + client = new RetellSDKClient(mockLogger); + + expect(logger.getSubLogger).not.toHaveBeenCalled(); + expect(Retell).toHaveBeenCalledWith({ apiKey: "test-retell-api-key" }); + }); + + it("should throw error when RETELL_API_KEY is not configured", async () => { + vi.doMock("@calcom/lib/constants", () => ({ + RETELL_API_KEY: undefined, + })); + + vi.resetModules(); + const { RetellSDKClient: TestRetellSDKClient } = await import("./client"); + + expect(() => { + new TestRetellSDKClient(); + }).toThrow("RETELL_API_KEY is not configured"); + + vi.doMock("@calcom/lib/constants", () => ({ + RETELL_API_KEY: "test-retell-api-key", + })); + }); + }); + + describe("LLM operations", () => { + beforeEach(() => { + client = new RetellSDKClient(mockLogger); + }); + + describe("createLLM", () => { + it("should create and return LLM", async () => { + const mockRequest: CreateLLMRequest = { + general_prompt: "Test prompt", + }; + const mockResponse = { llm_id: "test-llm-id" }; + + mockRetellInstance.llm.create.mockResolvedValue(mockResponse); + + const result = await client.createLLM(mockRequest); + + expect(mockRetellInstance.llm.create).toHaveBeenCalledWith(mockRequest); + expect(result).toEqual(mockResponse); + }); + }); + + describe("getLLM", () => { + it("should get LLM", async () => { + const llmId = "test-llm-id"; + const mockResponse = { llm_id: llmId, general_prompt: "Test prompt" }; + + mockRetellInstance.llm.retrieve.mockResolvedValue(mockResponse); + + const result = await client.getLLM(llmId); + + expect(mockRetellInstance.llm.retrieve).toHaveBeenCalledWith(llmId); + expect(result).toEqual(mockResponse); + }); + }); + + describe("updateLLM", () => { + it("should update LLM", async () => { + const llmId = "test-llm-id"; + const updateData: UpdateLLMRequest = { + general_prompt: "Updated prompt", + }; + const mockResponse = { llm_id: llmId }; + + mockRetellInstance.llm.update.mockResolvedValue(mockResponse); + + const result = await client.updateLLM(llmId, updateData); + + expect(mockRetellInstance.llm.update).toHaveBeenCalledWith(llmId, updateData); + expect(result).toEqual(mockResponse); + }); + }); + + describe("deleteLLM", () => { + it("should delete LLM", async () => { + const llmId = "test-llm-id"; + + mockRetellInstance.llm.delete.mockResolvedValue(undefined); + + await client.deleteLLM(llmId); + + expect(mockRetellInstance.llm.delete).toHaveBeenCalledWith(llmId); + }); + }); + }); + + describe("Agent operations", () => { + beforeEach(() => { + client = new RetellSDKClient(mockLogger); + }); + + describe("createAgent", () => { + it("should create agent", async () => { + const mockRequest: CreateAgentRequest = { + agent_name: "Test Agent", + response_engine: { type: "retell-llm", llm_id: "test-llm-id" }, + voice_id: "test-voice-id", + }; + const mockResponse = { agent_id: "test-agent-id", agent_name: "Test Agent" }; + + mockRetellInstance.agent.create.mockResolvedValue(mockResponse); + + const result = await client.createAgent(mockRequest); + + expect(mockRetellInstance.agent.create).toHaveBeenCalledWith(mockRequest); + expect(result).toEqual(mockResponse); + }); + }); + + describe("getAgent", () => { + it("should get agent", async () => { + const agentId = "test-agent-id"; + const mockResponse = { agent_id: agentId, agent_name: "Test Agent" }; + + mockRetellInstance.agent.retrieve.mockResolvedValue(mockResponse); + + const result = await client.getAgent(agentId); + + expect(mockRetellInstance.agent.retrieve).toHaveBeenCalledWith(agentId); + expect(result).toEqual(mockResponse); + }); + }); + + describe("updateAgent", () => { + it("should update agent", async () => { + const agentId = "test-agent-id"; + const updateData: UpdateAgentRequest = { + agent_name: "Updated Agent", + }; + const mockResponse = { agent_id: agentId, agent_name: "Updated Agent" }; + + mockRetellInstance.agent.update.mockResolvedValue(mockResponse); + + const result = await client.updateAgent(agentId, updateData); + + expect(mockRetellInstance.agent.update).toHaveBeenCalledWith(agentId, updateData); + expect(result).toEqual(mockResponse); + }); + }); + + describe("deleteAgent", () => { + it("should delete agent", async () => { + const agentId = "test-agent-id"; + + mockRetellInstance.agent.delete.mockResolvedValue(undefined); + + await client.deleteAgent(agentId); + + expect(mockRetellInstance.agent.delete).toHaveBeenCalledWith(agentId); + }); + }); + }); + + describe("Phone number operations", () => { + beforeEach(() => { + client = new RetellSDKClient(mockLogger); + }); + + describe("createPhoneNumber", () => { + it("should create phone number", async () => { + const phoneData: CreatePhoneNumberParams = { + area_code: 415, + nickname: "Test Phone", + }; + const mockResponse = { phone_number: "+14155551234" }; + + mockRetellInstance.phoneNumber.create.mockResolvedValue(mockResponse); + + const result = await client.createPhoneNumber(phoneData); + + expect(mockRetellInstance.phoneNumber.create).toHaveBeenCalledWith(phoneData); + expect(result).toEqual(mockResponse); + }); + }); + + describe("importPhoneNumber", () => { + it("should import phone number", async () => { + const importData: ImportPhoneNumberParams = { + phone_number: "+14155551234", + termination_uri: "https://example.com/webhook", + sip_trunk_auth_username: "username", + sip_trunk_auth_password: "password", + }; + const mockResponse = { phone_number: "+14155551234" }; + + mockRetellInstance.phoneNumber.import.mockResolvedValue(mockResponse); + + const result = await client.importPhoneNumber(importData); + + expect(mockRetellInstance.phoneNumber.import).toHaveBeenCalledWith(importData); + expect(result).toEqual(mockResponse); + }); + }); + + describe("deletePhoneNumber", () => { + it("should delete phone number", async () => { + const phoneNumber = "+14155551234"; + + mockRetellInstance.phoneNumber.delete.mockResolvedValue(undefined); + + await client.deletePhoneNumber(phoneNumber); + + expect(mockRetellInstance.phoneNumber.delete).toHaveBeenCalledWith(phoneNumber); + }); + }); + + describe("getPhoneNumber", () => { + it("should get phone number", async () => { + const phoneNumber = "+14155551234"; + const mockResponse = { phone_number: phoneNumber }; + + mockRetellInstance.phoneNumber.retrieve.mockResolvedValue(mockResponse); + + const result = await client.getPhoneNumber(phoneNumber); + + expect(mockRetellInstance.phoneNumber.retrieve).toHaveBeenCalledWith(phoneNumber); + expect(result).toEqual(mockResponse); + }); + }); + + describe("updatePhoneNumber", () => { + it("should update phone number", async () => { + const phoneNumber = "+14155551234"; + const updateData = { + inbound_agent_id: "new-inbound", + }; + const mockResponse = { phone_number: phoneNumber }; + + mockRetellInstance.phoneNumber.update.mockResolvedValue(mockResponse); + + const result = await client.updatePhoneNumber(phoneNumber, updateData); + + expect(mockRetellInstance.phoneNumber.update).toHaveBeenCalledWith(phoneNumber, updateData); + expect(result).toEqual(mockResponse); + }); + }); + }); + + describe("Call operations", () => { + beforeEach(() => { + client = new RetellSDKClient(mockLogger); + }); + + describe("createPhoneCall", () => { + it("should create phone call", async () => { + const callData = { + from_number: "+14155551234", + to_number: "+14155555678", + retell_llm_dynamic_variables: { + name: "John Doe", + email: "john@example.com", + } as RetellDynamicVariables, + }; + const mockResponse = { call_id: "test-call-id" }; + + mockRetellInstance.call.createPhoneCall.mockResolvedValue(mockResponse); + + const result = await client.createPhoneCall(callData); + + expect(mockRetellInstance.call.createPhoneCall).toHaveBeenCalledWith(callData); + expect(result).toEqual(mockResponse); + }); + + it("should handle undefined dynamic variables", async () => { + const callData = { + from_number: "+14155551234", + to_number: "+14155555678", + retell_llm_dynamic_variables: undefined, + }; + const mockResponse = { call_id: "test-call-id" }; + + mockRetellInstance.call.createPhoneCall.mockResolvedValue(mockResponse); + + const result = await client.createPhoneCall(callData); + + expect(mockRetellInstance.call.createPhoneCall).toHaveBeenCalledWith(callData); + expect(result).toEqual(mockResponse); + }); + }); + }); + + describe("Edge cases", () => { + beforeEach(() => { + client = new RetellSDKClient(mockLogger); + }); + + it("should handle special characters in phone numbers", async () => { + const phoneNumber = "+1 (415) 555-1234"; + const mockResponse = { phone_number: phoneNumber }; + + mockRetellInstance.phoneNumber.retrieve.mockResolvedValue(mockResponse); + + const result = await client.getPhoneNumber(phoneNumber); + + expect(mockRetellInstance.phoneNumber.retrieve).toHaveBeenCalledWith(phoneNumber); + expect(result).toEqual(mockResponse); + }); + }); +}); diff --git a/packages/features/ee/cal-ai-phone/providers/retell-ai/client.ts b/packages/features/ee/cal-ai-phone/providers/retell-ai/client.ts new file mode 100644 index 00000000000000..3c71d66ed248d9 --- /dev/null +++ b/packages/features/ee/cal-ai-phone/providers/retell-ai/client.ts @@ -0,0 +1,235 @@ +import { Retell } from "retell-sdk"; + +import { RETELL_API_KEY } from "@calcom/lib/constants"; +import logger from "@calcom/lib/logger"; + +import type { + RetellAIRepository, + CreateLLMRequest, + UpdateLLMRequest, + CreateAgentRequest, + UpdateAgentRequest, + RetellAgent, + CreatePhoneNumberParams, + RetellDynamicVariables, + ImportPhoneNumberParams, +} from "./types"; + +export class RetellSDKClient implements RetellAIRepository { + private client: Retell; + private logger: ReturnType; + + constructor(customLogger?: ReturnType) { + this.logger = customLogger || logger.getSubLogger({ prefix: ["retellSDKClient:"] }); + + if (!RETELL_API_KEY) { + throw new Error("RETELL_API_KEY is not configured"); + } + + this.client = new Retell({ + apiKey: RETELL_API_KEY, + }); + } + + async createLLM(data: CreateLLMRequest) { + this.logger.info("Creating LLM via SDK", { + eventTypeId: data.general_tools?.find((t) => "event_type_id" in t)?.event_type_id, + }); + + try { + const response = await this.client.llm.create(data); + + this.logger.info("LLM created successfully", { llmId: response.llm_id }); + return response; + } catch (error) { + this.logger.error("Failed to create LLM", { error }); + throw error; + } + } + + async getLLM(llmId: string) { + this.logger.info("Getting LLM via SDK", { llmId }); + + try { + const response = await this.client.llm.retrieve(llmId); + this.logger.info("LLM retrieved successfully", { llmId }); + return response; + } catch (error) { + this.logger.error("Failed to get LLM", { error, llmId }); + throw error; + } + } + + async updateLLM(llmId: string, data: UpdateLLMRequest) { + try { + this.logger.info("Updating LLM via SDK", { llmId }); + + const response = await this.client.llm.update(llmId, data); + + this.logger.info("LLM updated successfully", { llmId }); + return response; + } catch (error) { + this.logger.error("Failed to update LLM", { error, llmId }); + throw error; + } + } + + async deleteLLM(llmId: string) { + this.logger.info("Deleting LLM via SDK", { llmId }); + + try { + await this.client.llm.delete(llmId); + this.logger.info("LLM deleted successfully", { llmId }); + } catch (error) { + this.logger.error("Failed to delete LLM", { error, llmId }); + throw error; + } + } + + async createAgent(data: CreateAgentRequest): Promise { + this.logger.info("Creating agent via SDK", { + agentName: data.agent_name, + }); + + try { + const response = await this.client.agent.create(data); + + this.logger.info("Agent created successfully", { + agentId: response.agent_id, + agentName: data.agent_name, + }); + + return response; + } catch (error) { + this.logger.error("Failed to create agent", { + error, + agentName: data.agent_name, + }); + throw error; + } + } + + async getAgent(agentId: string): Promise { + this.logger.info("Getting agent via SDK", { agentId }); + + try { + const response = await this.client.agent.retrieve(agentId); + this.logger.info("Agent retrieved successfully", { agentId }); + + return response; + } catch (error) { + this.logger.error("Failed to get agent", { error, agentId }); + throw error; + } + } + + async updateAgent(agentId: string, data: UpdateAgentRequest): Promise { + this.logger.info("Updating agent via SDK", { agentId }); + + try { + const response = await this.client.agent.update(agentId, data); + this.logger.info("Agent updated successfully", { agentId }); + + return response; + } catch (error) { + this.logger.error("Failed to update agent", { error, agentId }); + throw error; + } + } + + async deleteAgent(agentId: string) { + this.logger.info("Deleting agent via SDK", { agentId }); + + try { + await this.client.agent.delete(agentId); + this.logger.info("Agent deleted successfully", { agentId }); + } catch (error) { + this.logger.error("Failed to delete agent", { error, agentId }); + throw error; + } + } + + async createPhoneNumber(data: CreatePhoneNumberParams) { + try { + const response = await this.client.phoneNumber.create({ + area_code: data.area_code, + inbound_agent_id: data.inbound_agent_id, + outbound_agent_id: data.outbound_agent_id, + nickname: data.nickname, + }); + return response; + } catch (error) { + this.logger.error("Failed to create phone number", { error }); + throw error; + } + } + + async importPhoneNumber(data: ImportPhoneNumberParams) { + try { + const response = await this.client.phoneNumber.import({ + phone_number: data.phone_number, + termination_uri: data.termination_uri, + sip_trunk_auth_username: data.sip_trunk_auth_username, + sip_trunk_auth_password: data.sip_trunk_auth_password, + nickname: data.nickname, + }); + return response; + } catch (error) { + this.logger.error("Failed to import phone number", { error }); + throw error; + } + } + + async deletePhoneNumber(phoneNumber: string) { + try { + await this.client.phoneNumber.delete(phoneNumber); + } catch (error) { + this.logger.error("Failed to delete phone number", { error, phoneNumber }); + throw error; + } + } + + async getPhoneNumber(phoneNumber: string) { + try { + const response = await this.client.phoneNumber.retrieve(phoneNumber); + return response; + } catch (error) { + this.logger.error("Failed to get phone number", { error, phoneNumber }); + throw error; + } + } + + async updatePhoneNumber( + phoneNumber: string, + data: { inbound_agent_id?: string | null; outbound_agent_id?: string | null } + ) { + try { + const response = await this.client.phoneNumber.update(phoneNumber, { + inbound_agent_id: data.inbound_agent_id, + outbound_agent_id: data.outbound_agent_id, + }); + return response; + } catch (error) { + this.logger.error("Failed to update phone number", { error, phoneNumber }); + throw error; + } + } + + async createPhoneCall(data: { + from_number: string; + to_number: string; + retell_llm_dynamic_variables?: RetellDynamicVariables; + }) { + try { + const response = await this.client.call.createPhoneCall({ + from_number: data.from_number, + to_number: data.to_number, + retell_llm_dynamic_variables: data.retell_llm_dynamic_variables, + }); + return response; + } catch (error) { + this.logger.error("Failed to create phone call", { error }); + throw error; + } + } +} diff --git a/packages/features/ee/cal-ai-phone/providers/retell-ai/errors.ts b/packages/features/ee/cal-ai-phone/providers/retell-ai/errors.ts new file mode 100644 index 00000000000000..8e9ad61294ec66 --- /dev/null +++ b/packages/features/ee/cal-ai-phone/providers/retell-ai/errors.ts @@ -0,0 +1,6 @@ +export class RetellAIError extends Error { + constructor(message: string, public readonly operation: string, public readonly originalError?: unknown) { + super(message); + this.name = "RetellAIError"; + } +} diff --git a/packages/features/ee/cal-ai-phone/providers/retell-ai/factory.test.ts b/packages/features/ee/cal-ai-phone/providers/retell-ai/factory.test.ts new file mode 100644 index 00000000000000..e89c47936bcb54 --- /dev/null +++ b/packages/features/ee/cal-ai-phone/providers/retell-ai/factory.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +import logger from "@calcom/lib/logger"; + +import type { AIPhoneServiceProviderConfig } from "../../interfaces/ai-phone-service.interface"; +import { RetellSDKClient } from "./client"; +import { RetellAIProviderFactory } from "./factory"; +import { RetellAIProvider } from "./provider"; + +vi.mock("@calcom/lib/logger", () => ({ + default: { + getSubLogger: vi.fn().mockReturnValue({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), + }, +})); + +vi.mock("./client", () => ({ + RetellSDKClient: vi.fn().mockImplementation((logger) => ({ + logger, + createLLM: vi.fn(), + getLLM: vi.fn(), + updateLLM: vi.fn(), + deleteLLM: vi.fn(), + createAgent: vi.fn(), + getAgent: vi.fn(), + updateAgent: vi.fn(), + deleteAgent: vi.fn(), + createPhoneNumber: vi.fn(), + importPhoneNumber: vi.fn(), + deletePhoneNumber: vi.fn(), + getPhoneNumber: vi.fn(), + updatePhoneNumber: vi.fn(), + createPhoneCall: vi.fn(), + })), +})); + +vi.mock("./provider", () => ({ + RetellAIProvider: vi.fn().mockImplementation((repository) => ({ + repository, + setupConfiguration: vi.fn(), + deleteConfiguration: vi.fn(), + updateLLMConfiguration: vi.fn(), + getLLMDetails: vi.fn(), + getAgent: vi.fn(), + updateAgent: vi.fn(), + createPhoneCall: vi.fn(), + createPhoneNumber: vi.fn(), + deletePhoneNumber: vi.fn(), + getPhoneNumber: vi.fn(), + updatePhoneNumber: vi.fn(), + importPhoneNumber: vi.fn(), + })), +})); + +describe("RetellAIProviderFactory", () => { + let factory: RetellAIProviderFactory; + + beforeEach(() => { + vi.clearAllMocks(); + factory = new RetellAIProviderFactory(); + }); + + describe("create", () => { + it("should create provider with logger when logging enabled", () => { + const config: AIPhoneServiceProviderConfig = { + enableLogging: true, + }; + + const provider = factory.create(config); + + expect(logger.getSubLogger).toHaveBeenCalledWith({ prefix: ["retellAIProvider:"] }); + const mockLoggerInstance = (logger.getSubLogger as any).mock.results[0].value; + expect(RetellSDKClient).toHaveBeenCalledWith(mockLoggerInstance); + expect(provider).toBeDefined(); + }); + + it("should create provider without logger when logging disabled", () => { + const config: AIPhoneServiceProviderConfig = { + enableLogging: false, + }; + + const provider = factory.create(config); + + expect(logger.getSubLogger).not.toHaveBeenCalled(); + expect(RetellSDKClient).toHaveBeenCalledWith(undefined); + expect(provider).toBeDefined(); + }); + }); + + describe("createWithConfig static method", () => { + it("should use custom logger when provided", () => { + const customLogger = { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + } as unknown as ReturnType; + + const provider = RetellAIProviderFactory.createWithConfig({ + enableLogging: true, + logger: customLogger, + }); + + expect(logger.getSubLogger).not.toHaveBeenCalled(); + expect(RetellSDKClient).toHaveBeenCalledWith(customLogger); + expect(provider).toBeDefined(); + }); + }); + + describe("dependency injection", () => { + it("should inject SDK client into provider", () => { + factory.create({}); + + const clientInstance = (RetellSDKClient as any).mock.results[0].value; + expect(RetellAIProvider).toHaveBeenCalledWith(clientInstance); + }); + }); +}); diff --git a/packages/features/ee/cal-ai-phone/providers/retell-ai/factory.ts b/packages/features/ee/cal-ai-phone/providers/retell-ai/factory.ts new file mode 100644 index 00000000000000..1e9c183c286562 --- /dev/null +++ b/packages/features/ee/cal-ai-phone/providers/retell-ai/factory.ts @@ -0,0 +1,32 @@ +import logger from "@calcom/lib/logger"; + +import type { + AIPhoneServiceProvider, + AIPhoneServiceProviderFactory, + AIPhoneServiceProviderConfig, +} from "../../interfaces/ai-phone-service.interface"; +import { RetellSDKClient } from "./client"; +import { RetellAIProvider } from "./provider"; + +export class RetellAIProviderFactory implements AIPhoneServiceProviderFactory { + create(config: AIPhoneServiceProviderConfig): AIPhoneServiceProvider { + const log = + config.enableLogging !== false ? logger.getSubLogger({ prefix: ["retellAIProvider:"] }) : undefined; + + const sdkClient = new RetellSDKClient(log); + return new RetellAIProvider(sdkClient); + } + + static createWithConfig(config?: { + enableLogging?: boolean; + logger?: ReturnType; + }): AIPhoneServiceProvider { + const enableLogging = config?.enableLogging ?? true; + const log = enableLogging + ? config?.logger || logger.getSubLogger({ prefix: ["retellAIProvider:"] }) + : undefined; + + const sdkClient = new RetellSDKClient(log); + return new RetellAIProvider(sdkClient); + } +} diff --git a/packages/features/ee/cal-ai-phone/providers/retell-ai/index.ts b/packages/features/ee/cal-ai-phone/providers/retell-ai/index.ts new file mode 100644 index 00000000000000..08d2c89ea2111a --- /dev/null +++ b/packages/features/ee/cal-ai-phone/providers/retell-ai/index.ts @@ -0,0 +1,98 @@ +export { RetellAIService } from "./service"; +export { RetellAIProvider } from "./provider"; +export { RetellAIProviderFactory } from "./factory"; +export { RetellSDKClient } from "./client"; +export { RetellAIError } from "./errors"; + +export type { + RetellAIRepository, + CreateLLMRequest, + CreateAgentRequest, + UpdateLLMRequest, + AIConfigurationSetup, + AIConfigurationDeletion, + DeletionResult, +} from "./types"; + +// ===== USAGE EXAMPLES ===== +/* +// Using the provider abstraction +const factory = new RetellAIProviderFactory(); +const provider = factory.create({ apiKey: "your-api-key" }); + +// Setup AI configuration +const { modelId, agentId } = await provider.setupConfiguration({ + calApiKey: "cal_live_123...", + timeZone: "America/New_York", + eventTypeId: 12345, +}); + +// Update model configuration +await provider.updateModelConfiguration(modelId, { + generalPrompt: "You are a helpful assistant...", + beginMessage: "Hello, I'm calling to confirm your appointment...", +}); + +// Delete AI configuration with fault tolerance +const deletionResult = await provider.deleteConfiguration({ + modelId, + agentId, +}); + +if (!deletionResult.success) { + console.error("Deletion had errors:", deletionResult.errors); +} else { + console.log("Successfully deleted AI configuration"); +} + +// Create phone call +const call = await provider.createPhoneCall({ + fromNumber: "+1234567890", + toNumber: "+0987654321", + dynamicVariables: { + name: "John Doe", + company: "Acme Corp", + email: "john@acme.com", + }, +}); + +// Legacy usage (direct service access still supported) + +const service = new RetellAIService(apiClient); + +// Setup AI configuration +const { llmId, agentId } = await service.setupAIConfiguration({ + calApiKey: "cal_live_123...", + timeZone: "America/New_York", + eventTypeId: 12345, +}); + +// Update LLM +await service.updateLLMConfiguration(llmId, { + generalPrompt: "You are a helpful assistant...", + beginMessage: "Hello, I'm calling to confirm your appointment...", +}); + +// Delete AI configuration with fault tolerance +const deletionResult = await service.deleteAIConfiguration({ + llmId, + agentId, +}); + +if (!deletionResult.success) { + console.error("Deletion had errors:", deletionResult.errors); +} else { + console.log("Successfully deleted AI configuration"); +} + +// Create phone call +const call = await service.createPhoneCall({ + fromNumber: "+1234567890", + toNumber: "+0987654321", + dynamicVariables: { + name: "John Doe", + company: "Acme Corp", + email: "john@acme.com", + }, +}); +*/ diff --git a/packages/features/ee/cal-ai-phone/providers/retell-ai/provider.test.ts b/packages/features/ee/cal-ai-phone/providers/retell-ai/provider.test.ts new file mode 100644 index 00000000000000..594a087a181af3 --- /dev/null +++ b/packages/features/ee/cal-ai-phone/providers/retell-ai/provider.test.ts @@ -0,0 +1,567 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +import type { + AIPhoneServiceConfiguration, + AIPhoneServiceDeletion, + AIPhoneServiceCallData, + AIPhoneServiceCreatePhoneNumberParams, + AIPhoneServiceImportPhoneNumberParams, + AIPhoneServiceUpdatePhoneNumberParams, + AIPhoneServiceUpdateAgentParams, + AIPhoneServiceUpdateModelParams, +} from "../../interfaces/ai-phone-service.interface"; +import { RetellAIProvider } from "./provider"; +import type { RetellAIService } from "./service"; +import type { RetellAIRepository } from "./types"; + +function createMockRetellAIService(overrides: Partial = {}): RetellAIService { + const defaultMocks = { + setupAIConfiguration: vi.fn(), + deleteAIConfiguration: vi.fn(), + updateLLMConfiguration: vi.fn(), + getLLMDetails: vi.fn(), + getAgent: vi.fn(), + updateAgent: vi.fn(), + createPhoneCall: vi.fn(), + createPhoneNumber: vi.fn(), + importPhoneNumber: vi.fn(), + deletePhoneNumber: vi.fn(), + getPhoneNumber: vi.fn(), + updatePhoneNumber: vi.fn(), + generatePhoneNumberCheckoutSession: vi.fn(), + cancelPhoneNumberSubscription: vi.fn(), + updatePhoneNumberWithAgents: vi.fn(), + listAgents: vi.fn(), + getAgentWithDetails: vi.fn(), + createAgent: vi.fn(), + updateAgentConfiguration: vi.fn(), + deleteAgent: vi.fn(), + createTestCall: vi.fn(), + }; + + return { ...defaultMocks, ...overrides } as unknown as RetellAIService; +} + +describe("RetellAIProvider", () => { + let mockRepository: RetellAIRepository; + let provider: RetellAIProvider; + let mockService: RetellAIService; + + beforeEach(() => { + mockRepository = { + createLLM: vi.fn().mockResolvedValue({ llm_id: "test-llm-id" }), + getLLM: vi.fn().mockResolvedValue({ llm_id: "test-llm-id", general_prompt: "test prompt" }), + updateLLM: vi.fn().mockResolvedValue({ llm_id: "test-llm-id" }), + deleteLLM: vi.fn().mockResolvedValue(undefined), + + // Agent operations + createAgent: 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), + + // Phone number operations + createPhoneNumber: vi.fn().mockResolvedValue({ phone_number: "+1234567890" }), + importPhoneNumber: vi.fn().mockResolvedValue({ phone_number: "+1234567890" }), + deletePhoneNumber: vi.fn().mockResolvedValue(undefined), + getPhoneNumber: vi.fn().mockResolvedValue({ phone_number: "+1234567890" }), + updatePhoneNumber: vi.fn().mockResolvedValue({ phone_number: "+1234567890" }), + + // Call operations + createPhoneCall: vi.fn().mockResolvedValue({ call_id: "test-call-id" }), + }; + + // Create a comprehensive mock service using helper function + mockService = createMockRetellAIService(); + + provider = new RetellAIProvider(mockRepository, mockService); + }); + + describe("setupConfiguration", () => { + beforeEach(() => { + mockService.setupAIConfiguration = vi.fn().mockResolvedValue({ + llmId: "test-llm-id", + agentId: "test-agent-id", + }); + }); + + it("should setup AI configuration and return llmId and agentId", async () => { + const config: AIPhoneServiceConfiguration = { + calApiKey: "test-cal-api-key", + timeZone: "America/New_York", + eventTypeId: 123, + generalPrompt: "Test prompt", + beginMessage: "Hello!", + generalTools: [ + { + type: "check_availability_cal", + name: "check_availability", + cal_api_key: "test-key", + event_type_id: 123, + timezone: "America/New_York", + }, + ], + }; + + const result = await provider.setupConfiguration(config); + + expect(mockService.setupAIConfiguration).toHaveBeenCalledWith({ + calApiKey: config.calApiKey, + timeZone: config.timeZone, + eventTypeId: config.eventTypeId, + generalPrompt: config.generalPrompt, + beginMessage: config.beginMessage, + generalTools: config.generalTools, + }); + + expect(result).toEqual({ + modelId: "test-llm-id", + agentId: "test-agent-id", + }); + }); + + it("should handle missing optional fields in configuration", async () => { + const minimalConfig: AIPhoneServiceConfiguration = { + generalPrompt: "Minimal prompt", + }; + + await provider.setupConfiguration(minimalConfig); + + expect(mockService.setupAIConfiguration).toHaveBeenCalledWith({ + calApiKey: undefined, + timeZone: undefined, + eventTypeId: undefined, + generalPrompt: "Minimal prompt", + beginMessage: undefined, + generalTools: undefined, + }); + }); + }); + + describe("deleteConfiguration", () => { + beforeEach(() => { + mockService.deleteAIConfiguration = vi.fn().mockResolvedValue({ + success: true, + errors: [], + deleted: { llm: true, agent: true }, + }); + }); + + it("should delete AI configuration and return result", async () => { + const config: AIPhoneServiceDeletion = { + modelId: "test-llm-id", + agentId: "test-agent-id", + }; + + const result = await provider.deleteConfiguration(config); + + expect(mockService.deleteAIConfiguration).toHaveBeenCalledWith({ + llmId: "test-llm-id", + agentId: "test-agent-id", + }); + + expect(result).toEqual({ + success: true, + errors: [], + deleted: { model: true, agent: true }, + }); + }); + + it("should handle partial deletion results", async () => { + mockService.deleteAIConfiguration = vi.fn().mockResolvedValue({ + success: false, + errors: ["Failed to delete LLM"], + deleted: { llm: false, agent: true }, + }); + + const config: AIPhoneServiceDeletion = { + modelId: "test-llm-id", + agentId: "test-agent-id", + }; + + const result = await provider.deleteConfiguration(config); + + expect(result).toEqual({ + success: false, + errors: ["Failed to delete LLM"], + deleted: { model: false, agent: true }, + }); + }); + }); + + describe("updateModelConfiguration", () => { + let mockService: RetellAIService; + let testProvider: RetellAIProvider; + let updateLLMConfigurationMock: ReturnType; + + beforeEach(() => { + updateLLMConfigurationMock = vi.fn().mockResolvedValue({ llm_id: "test-llm-id" }); + + mockService = createMockRetellAIService({ + updateLLMConfiguration: updateLLMConfigurationMock, + }); + + testProvider = new RetellAIProvider(mockRepository, mockService); + }); + + it("should update model configuration", async () => { + const modelId = "test-llm-id"; + const updateData: AIPhoneServiceUpdateModelParams = { + general_prompt: "Updated prompt", + begin_message: "Updated begin message", + }; + + const result = await testProvider.updateModelConfiguration(modelId, updateData); + + expect(updateLLMConfigurationMock).toHaveBeenCalledWith(modelId, updateData); + expect(result).toEqual({ llm_id: "test-llm-id" }); + }); + }); + + describe("getModelDetails", () => { + let mockService: RetellAIService; + let testProvider: RetellAIProvider; + let getLLMDetailsMock: ReturnType; + + beforeEach(() => { + getLLMDetailsMock = vi.fn().mockResolvedValue({ llm_id: "test-llm-id", general_prompt: "Test prompt" }); + + mockService = createMockRetellAIService({ + getLLMDetails: getLLMDetailsMock, + }); + + testProvider = new RetellAIProvider(mockRepository, mockService); + }); + + it("should get model details", async () => { + const modelId = "test-llm-id"; + + const result = await testProvider.getModelDetails(modelId); + + expect(getLLMDetailsMock).toHaveBeenCalledWith(modelId); + expect(result).toEqual({ llm_id: "test-llm-id", general_prompt: "Test prompt" }); + }); + }); + + describe("getAgent", () => { + let mockService: RetellAIService; + let testProvider: RetellAIProvider; + let getAgentMock: ReturnType; + + beforeEach(() => { + getAgentMock = vi.fn().mockResolvedValue({ agent_id: "test-agent-id", agent_name: "Test Agent" }); + + mockService = createMockRetellAIService({ + getAgent: getAgentMock, + }); + + testProvider = new RetellAIProvider(mockRepository, mockService); + }); + + it("should get agent details", async () => { + const agentId = "test-agent-id"; + + const result = await testProvider.getAgent(agentId); + + expect(getAgentMock).toHaveBeenCalledWith(agentId); + expect(result).toEqual({ agent_id: "test-agent-id", agent_name: "Test Agent" }); + }); + }); + + describe("updateAgent", () => { + let mockService: RetellAIService; + let testProvider: RetellAIProvider; + let updateAgentMock: ReturnType; + + beforeEach(() => { + updateAgentMock = vi.fn().mockResolvedValue({ agent_id: "test-agent-id" }); + + mockService = createMockRetellAIService({ + updateAgent: updateAgentMock, + }); + + testProvider = new RetellAIProvider(mockRepository, mockService); + }); + + it("should update agent", async () => { + const agentId = "test-agent-id"; + const updateData: AIPhoneServiceUpdateAgentParams = { + agent_name: "Updated Agent Name", + }; + + const result = await testProvider.updateAgent(agentId, updateData); + + expect(updateAgentMock).toHaveBeenCalledWith(agentId, updateData); + expect(result).toEqual({ agent_id: "test-agent-id" }); + }); + }); + + describe("createPhoneCall", () => { + let mockService: RetellAIService; + let testProvider: RetellAIProvider; + let createPhoneCallMock: ReturnType; + + beforeEach(() => { + createPhoneCallMock = vi.fn().mockResolvedValue({ call_id: "test-call-id" }); + + mockService = createMockRetellAIService({ + createPhoneCall: createPhoneCallMock, + }); + + testProvider = new RetellAIProvider(mockRepository, mockService); + }); + + it("should create phone call", async () => { + const callData: AIPhoneServiceCallData = { + from_number: "+1234567890", + to_number: "+0987654321", + retell_llm_dynamic_variables: { + name: "John Doe", + email: "john@example.com", + }, + }; + + const result = await testProvider.createPhoneCall(callData); + + expect(createPhoneCallMock).toHaveBeenCalledWith(callData); + expect(result).toEqual({ call_id: "test-call-id" }); + }); + + it("should handle call data without dynamic variables", async () => { + const callData: AIPhoneServiceCallData = { + from_number: "+1234567890", + to_number: "+0987654321", + }; + + await testProvider.createPhoneCall(callData); + + expect(createPhoneCallMock).toHaveBeenCalledWith(callData); + }); + }); + + describe("createPhoneNumber", () => { + let mockService: RetellAIService; + let testProvider: RetellAIProvider; + let createPhoneNumberMock: ReturnType; + + beforeEach(() => { + createPhoneNumberMock = vi.fn().mockResolvedValue({ phone_number: "+1234567890" }); + + mockService = createMockRetellAIService({ + createPhoneNumber: createPhoneNumberMock, + }); + + testProvider = new RetellAIProvider(mockRepository, mockService); + }); + + it("should create phone number with proper parameter mapping", async () => { + const phoneNumberData: AIPhoneServiceCreatePhoneNumberParams = { + area_code: 415, + nickname: "Test Phone", + inbound_agent_id: "inbound-agent-id", + outbound_agent_id: "outbound-agent-id", + }; + + const result = await testProvider.createPhoneNumber(phoneNumberData); + + expect(createPhoneNumberMock).toHaveBeenCalledWith({ + area_code: 415, + nickname: "Test Phone", + inbound_agent_id: "inbound-agent-id", + outbound_agent_id: "outbound-agent-id", + }); + expect(result).toEqual({ phone_number: "+1234567890" }); + }); + + it("should handle optional parameters in phone number creation", async () => { + const minimalData: AIPhoneServiceCreatePhoneNumberParams = { + area_code: 415, + }; + + await testProvider.createPhoneNumber(minimalData); + + expect(createPhoneNumberMock).toHaveBeenCalledWith({ + area_code: 415, + nickname: undefined, + inbound_agent_id: undefined, + outbound_agent_id: undefined, + }); + }); + }); + + describe("importPhoneNumber", () => { + let mockService: RetellAIService; + let testProvider: RetellAIProvider; + let importPhoneNumberMock: ReturnType; + + beforeEach(() => { + importPhoneNumberMock = vi.fn().mockResolvedValue({ phone_number: "+1234567890" }); + + mockService = createMockRetellAIService({ + importPhoneNumber: importPhoneNumberMock, + }); + + testProvider = new RetellAIProvider(mockRepository, mockService); + }); + + it("should import phone number", async () => { + const importData: AIPhoneServiceImportPhoneNumberParams = { + phone_number: "+1234567890", + termination_uri: "https://example.com/webhook", + sip_trunk_auth_username: "username", + sip_trunk_auth_password: "password", + nickname: "Imported Phone", + userId: 123, + }; + + const result = await testProvider.importPhoneNumber(importData); + + expect(importPhoneNumberMock).toHaveBeenCalledWith(importData); + expect(result).toEqual({ phone_number: "+1234567890" }); + }); + }); + + describe("deletePhoneNumber", () => { + let mockService: RetellAIService; + let testProvider: RetellAIProvider; + let deletePhoneNumberMock: ReturnType; + + beforeEach(() => { + deletePhoneNumberMock = vi.fn().mockResolvedValue(undefined); + + mockService = createMockRetellAIService({ + deletePhoneNumber: deletePhoneNumberMock, + }); + + testProvider = new RetellAIProvider(mockRepository, mockService); + }); + + it("should delete phone number", async () => { + const deleteParams = { + phoneNumber: "+1234567890", + userId: 123, + deleteFromDB: true, + }; + + await testProvider.deletePhoneNumber(deleteParams); + + expect(deletePhoneNumberMock).toHaveBeenCalledWith(deleteParams); + }); + + it("should handle deletion without database cleanup", async () => { + const deleteParams = { + phoneNumber: "+1234567890", + userId: 123, + deleteFromDB: false, + }; + + await testProvider.deletePhoneNumber(deleteParams); + + expect(deletePhoneNumberMock).toHaveBeenCalledWith(deleteParams); + }); + }); + + describe("getPhoneNumber", () => { + let mockService: RetellAIService; + let testProvider: RetellAIProvider; + let getPhoneNumberMock: ReturnType; + + beforeEach(() => { + getPhoneNumberMock = vi.fn().mockResolvedValue({ phone_number: "+1234567890" }); + + mockService = createMockRetellAIService({ + getPhoneNumber: getPhoneNumberMock, + }); + + testProvider = new RetellAIProvider(mockRepository, mockService); + }); + + it("should get phone number details", async () => { + const phoneNumber = "+1234567890"; + + const result = await testProvider.getPhoneNumber(phoneNumber); + + expect(getPhoneNumberMock).toHaveBeenCalledWith(phoneNumber); + expect(result).toEqual({ phone_number: "+1234567890" }); + }); + }); + + describe("updatePhoneNumber", () => { + let mockService: RetellAIService; + let testProvider: RetellAIProvider; + let updatePhoneNumberMock: ReturnType; + + beforeEach(() => { + updatePhoneNumberMock = vi.fn().mockResolvedValue({ phone_number: "+1234567890" }); + + mockService = createMockRetellAIService({ + updatePhoneNumber: updatePhoneNumberMock, + }); + + testProvider = new RetellAIProvider(mockRepository, mockService); + }); + + it("should update phone number with proper parameter mapping", async () => { + const phoneNumber = "+1234567890"; + const updateData: AIPhoneServiceUpdatePhoneNumberParams = { + inbound_agent_id: "new-inbound-agent", + outbound_agent_id: "new-outbound-agent", + }; + + const result = await testProvider.updatePhoneNumber(phoneNumber, updateData); + + expect(updatePhoneNumberMock).toHaveBeenCalledWith(phoneNumber, { + inbound_agent_id: "new-inbound-agent", + outbound_agent_id: "new-outbound-agent", + }); + expect(result).toEqual({ phone_number: "+1234567890" }); + }); + + it("should handle partial updates", async () => { + const phoneNumber = "+1234567890"; + const updateData: AIPhoneServiceUpdatePhoneNumberParams = { + inbound_agent_id: "new-inbound-agent", + }; + + await testProvider.updatePhoneNumber(phoneNumber, updateData); + + expect(updatePhoneNumberMock).toHaveBeenCalledWith(phoneNumber, { + inbound_agent_id: "new-inbound-agent", + outbound_agent_id: undefined, + }); + }); + + it("should handle null values in update data", async () => { + const phoneNumber = "+1234567890"; + const updateData: AIPhoneServiceUpdatePhoneNumberParams = { + inbound_agent_id: null, + outbound_agent_id: "new-outbound-agent", + }; + + await testProvider.updatePhoneNumber(phoneNumber, updateData); + + expect(updatePhoneNumberMock).toHaveBeenCalledWith(phoneNumber, { + inbound_agent_id: null, + outbound_agent_id: "new-outbound-agent", + }); + }); + }); + + describe("error handling", () => { + it("should propagate errors from service layer", async () => { + const setupAIConfigurationMock = vi.fn().mockRejectedValue(new Error("Service error")); + + const mockService = createMockRetellAIService({ + setupAIConfiguration: setupAIConfigurationMock, + }); + + const testProvider = new RetellAIProvider(mockRepository, mockService); + + const config: AIPhoneServiceConfiguration = { + generalPrompt: "Test prompt", + }; + + await expect(testProvider.setupConfiguration(config)).rejects.toThrow("Service error"); + }); + }); +}); diff --git a/packages/features/ee/cal-ai-phone/providers/retell-ai/provider.ts b/packages/features/ee/cal-ai-phone/providers/retell-ai/provider.ts new file mode 100644 index 00000000000000..532538e1022738 --- /dev/null +++ b/packages/features/ee/cal-ai-phone/providers/retell-ai/provider.ts @@ -0,0 +1,233 @@ +import type { + AIPhoneServiceProvider, + AIPhoneServiceConfiguration, + AIPhoneServiceDeletion, + AIPhoneServiceDeletionResult, + AIPhoneServiceCallData, + AIPhoneServiceModel, + AIPhoneServiceCall, + AIPhoneServicePhoneNumber, + AIPhoneServiceAgent, + AIPhoneServiceUpdateModelParams, + AIPhoneServiceUpdateAgentParams, + AIPhoneServiceCreatePhoneNumberParams, + AIPhoneServiceImportPhoneNumberParams, + AIPhoneServiceUpdatePhoneNumberParams, + AIPhoneServiceAgentListItem, +} from "../../interfaces/ai-phone-service.interface"; +import { RetellAIService } from "./service"; +import type { RetellAIRepository, RetellAgentWithDetails } from "./types"; + +export class RetellAIProvider implements AIPhoneServiceProvider { + private service: RetellAIService; + + constructor(repository: RetellAIRepository, service?: RetellAIService) { + this.service = service || new RetellAIService(repository); + } + + async setupConfiguration(config: AIPhoneServiceConfiguration): Promise<{ + modelId: string; + agentId: string; + }> { + const result = await this.service.setupAIConfiguration({ + calApiKey: config.calApiKey, + timeZone: config.timeZone, + eventTypeId: config.eventTypeId, + generalPrompt: config.generalPrompt, + beginMessage: config.beginMessage, + generalTools: config.generalTools, + }); + + return { + modelId: result.llmId, + agentId: result.agentId, + }; + } + + async deleteConfiguration(config: AIPhoneServiceDeletion): Promise { + const result = await this.service.deleteAIConfiguration({ + llmId: config.modelId, + agentId: config.agentId, + }); + + return { + success: result.success, + errors: result.errors, + deleted: { + model: result.deleted.llm, + agent: result.deleted.agent, + }, + }; + } + + async updateModelConfiguration( + modelId: string, + data: AIPhoneServiceUpdateModelParams + ): Promise { + const result = await this.service.updateLLMConfiguration(modelId, data); + return result; + } + + async getModelDetails(modelId: string): Promise { + const result = await this.service.getLLMDetails(modelId); + return result; + } + + async getAgent(agentId: string): Promise { + const agent = await this.service.getAgent(agentId); + return agent; + } + + async updateAgent(agentId: string, data: AIPhoneServiceUpdateAgentParams): Promise { + const agent = await this.service.updateAgent(agentId, data); + return agent; + } + + async createPhoneCall(data: AIPhoneServiceCallData): Promise { + const result = await this.service.createPhoneCall(data); + + return result; + } + + async createPhoneNumber(data: AIPhoneServiceCreatePhoneNumberParams): Promise { + const result = await this.service.createPhoneNumber({ + area_code: data.area_code, + nickname: data.nickname, + inbound_agent_id: data.inbound_agent_id, + outbound_agent_id: data.outbound_agent_id, + }); + + return result; + } + + async importPhoneNumber(data: AIPhoneServiceImportPhoneNumberParams): Promise { + const result = await this.service.importPhoneNumber(data); + return result; + } + + async deletePhoneNumber({ + phoneNumber, + userId, + deleteFromDB, + }: { + phoneNumber: string; + userId: number; + deleteFromDB: boolean; + }): Promise { + await this.service.deletePhoneNumber({ phoneNumber, userId, deleteFromDB }); + } + + async getPhoneNumber(phoneNumber: string): Promise { + const result = await this.service.getPhoneNumber(phoneNumber); + return result; + } + + async updatePhoneNumber( + phoneNumber: string, + data: AIPhoneServiceUpdatePhoneNumberParams + ): Promise { + const result = await this.service.updatePhoneNumber(phoneNumber, { + inbound_agent_id: data.inbound_agent_id, + outbound_agent_id: data.outbound_agent_id, + }); + + return result; + } + + async generatePhoneNumberCheckoutSession(params: { + userId: number; + teamId?: number; + agentId?: string | null; + workflowId?: string; + }): Promise<{ url: string; message: string }> { + return await this.service.generatePhoneNumberCheckoutSession(params); + } + + async cancelPhoneNumberSubscription(params: { + phoneNumberId: number; + userId: number; + }): Promise<{ success: boolean; message: string }> { + return await this.service.cancelPhoneNumberSubscription(params); + } + + async updatePhoneNumberWithAgents(params: { + phoneNumber: string; + userId: number; + inboundAgentId?: string | null; + outboundAgentId?: string | null; + }): Promise<{ message: string }> { + return await this.service.updatePhoneNumberWithAgents(params); + } + + async listAgents(params: { + userId: number; + teamId?: number; + scope?: "personal" | "team" | "all"; + }): Promise<{ + totalCount: number; + filtered: AIPhoneServiceAgentListItem[]; + }> { + return await this.service.listAgents(params); + } + + async getAgentWithDetails(params: { + id: string; + userId: number; + teamId?: number; + }): Promise { + return await this.service.getAgentWithDetails(params); + } + + async createAgent(params: { + name?: string; + userId: number; + teamId?: number; + workflowStepId?: number; + generalPrompt?: string; + beginMessage?: string; + generalTools?: any; + voiceId?: string; + userTimeZone: string; + }): Promise<{ + id: string; + providerAgentId: string; + message: string; + }> { + const result = await this.service.createAgent(params); + return { + id: result.id, + providerAgentId: result.retellAgentId, + message: result.message, + }; + } + + async updateAgentConfiguration(params: { + id: string; + userId: number; + name?: string; + enabled?: boolean; + generalPrompt?: string | null; + beginMessage?: string | null; + generalTools?: any; + voiceId?: string; + }): Promise<{ message: string }> { + return await this.service.updateAgentConfiguration(params); + } + + async deleteAgent(params: { id: string; userId: number; teamId?: number }): Promise<{ message: string }> { + return await this.service.deleteAgent(params); + } + + async createTestCall(params: { + agentId: string; + phoneNumber?: string; + userId: number; + teamId?: number; + }): Promise<{ + callId: string; + status: string; + message: string; + }> { + return await this.service.createTestCall(params); + } +} diff --git a/packages/features/ee/cal-ai-phone/providers/retell-ai/service-mappers.ts b/packages/features/ee/cal-ai-phone/providers/retell-ai/service-mappers.ts new file mode 100644 index 00000000000000..8e8fb156d0ad0b --- /dev/null +++ b/packages/features/ee/cal-ai-phone/providers/retell-ai/service-mappers.ts @@ -0,0 +1,204 @@ +/** + * Service layer mappers for RetellAI + * Maps between service/interface types and internal Retell types + */ +import type { + AIPhoneServiceConfiguration, + AIPhoneServiceUpdateModelParams, + AIPhoneServiceTools, +} from "../../interfaces/ai-phone-service.interface"; +import type { + RetellLLMGeneralTools, + UpdateLLMRequest, + CreateLLMRequest, + CreateAgentRequest, + UpdateAgentRequest, + RetellAgentWithDetails, + Language, + Agent, + RetellAgent, + RetellLLM, +} from "./types"; + +export class RetellServiceMapper { + /** + * Maps AI configuration to Retell tools format + */ + static buildGeneralTools(config: AIPhoneServiceConfiguration): NonNullable { + const tools: NonNullable = [ + { + type: "end_call", + name: "end_call", + description: "Hang up the call, triggered only after appointment successfully scheduled.", + }, + ]; + + // Add calendar tools if configured + if (config.calApiKey && config.eventTypeId && config.timeZone) { + tools.push( + { + type: "check_availability_cal" as const, + name: "check_availability", + cal_api_key: config.calApiKey, + event_type_id: config.eventTypeId, + timezone: config.timeZone, + }, + { + type: "book_appointment_cal" as const, + name: "book_appointment", + cal_api_key: config.calApiKey, + event_type_id: config.eventTypeId, + timezone: config.timeZone, + } + ); + } + + // Add any additional tools from config + if (config.generalTools) { + tools.push(...config.generalTools); + } + + return tools; + } + + /** + * Maps AI configuration to CreateLLMRequest + */ + static mapToCreateLLMRequest( + config: AIPhoneServiceConfiguration, + generalTools: RetellLLMGeneralTools + ): CreateLLMRequest { + return { + general_prompt: config.generalPrompt, + begin_message: config.beginMessage, + general_tools: generalTools, + }; + } + + /** + * Maps AI configuration to CreateAgentRequest + */ + static mapToCreateAgentRequest( + llmId: string, + eventTypeId?: number, + voiceId = "11labs-Adrian" + ): CreateAgentRequest { + return { + response_engine: { llm_id: llmId, type: "retell-llm" }, + agent_name: `agent-${eventTypeId || "default"}-${Date.now()}`, + voice_id: voiceId, + }; + } + + /** + * Maps update model params to Retell format + */ + static mapToUpdateLLMRequest(data: AIPhoneServiceUpdateModelParams): UpdateLLMRequest { + return { + general_prompt: data.general_prompt, + begin_message: data.begin_message, + general_tools: data.general_tools ?? null, + }; + } + + /** + * Maps agent update data to Retell format + */ + static mapToUpdateAgentRequest(data: { + agent_name?: string | null; + voice_id?: string; + language?: Language; + responsiveness?: number; + interruption_sensitivity?: number; + }): UpdateAgentRequest { + return data; + } + + /** + * Maps phone number update data + */ + static mapPhoneNumberUpdateData( + inboundAgentId?: string | null, + outboundAgentId?: string | null + ): { inbound_agent_id?: string | null; outbound_agent_id?: string | null } { + const updateData: { inbound_agent_id?: string | null; outbound_agent_id?: string | null } = {}; + + if (inboundAgentId !== undefined) { + updateData.inbound_agent_id = inboundAgentId; + } + + if (outboundAgentId !== undefined) { + updateData.outbound_agent_id = outboundAgentId; + } + + return updateData; + } + + /** + * Format agent for listing response + */ + static formatAgentForList(agent: Agent) { + return { + id: agent.id, + name: agent.name, + retellAgentId: agent.retellAgentId, + enabled: agent.enabled, + userId: agent.userId, + teamId: agent.teamId, + createdAt: agent.createdAt, + updatedAt: agent.updatedAt, + outboundPhoneNumbers: agent.outboundPhoneNumbers, + team: agent.team, + user: agent.user, + }; + } + + /** + * Format agent details response + */ + static formatAgentDetails( + agent: Agent, + retellAgent: RetellAgent, + llmDetails: RetellLLM + ): RetellAgentWithDetails { + return { + id: agent.id, + name: agent.name, + retellAgentId: agent.retellAgentId, + enabled: agent.enabled, + userId: agent.userId, + teamId: agent.teamId, + outboundPhoneNumbers: agent.outboundPhoneNumbers, + retellData: { + agentId: retellAgent.agent_id, + agentName: retellAgent.agent_name, + voiceId: retellAgent.voice_id, + responseEngine: retellAgent.response_engine, + language: retellAgent.language, + responsiveness: retellAgent.responsiveness, + interruptionSensitivity: retellAgent.interruption_sensitivity, + generalPrompt: llmDetails.general_prompt, + beginMessage: llmDetails.begin_message, + generalTools: llmDetails.general_tools, + llmId: llmDetails.llm_id, + }, + createdAt: agent.createdAt, + updatedAt: agent.updatedAt, + }; + } + + /** + * Extract LLM update data from params + */ + static extractLLMUpdateData( + generalPrompt?: string | null, + beginMessage?: string | null, + generalTools?: AIPhoneServiceTools + ): UpdateLLMRequest { + return { + general_prompt: generalPrompt, + begin_message: beginMessage, + general_tools: generalTools, + }; + } +} diff --git a/packages/features/ee/cal-ai-phone/providers/retell-ai/service.test.ts b/packages/features/ee/cal-ai-phone/providers/retell-ai/service.test.ts new file mode 100644 index 00000000000000..04b6aeadf26b6c --- /dev/null +++ b/packages/features/ee/cal-ai-phone/providers/retell-ai/service.test.ts @@ -0,0 +1,883 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums"; + +import { RetellAIError } from "./errors"; +import { RetellAIService } from "./service"; +import type { RetellAIRepository } from "./types"; + +vi.mock("@calcom/lib/server/repository/phoneNumber", () => ({ + PhoneNumberRepository: { + createPhoneNumber: vi.fn(), + findMinimalPhoneNumber: vi.fn(), + deletePhoneNumber: vi.fn(), + findByPhoneNumberAndUserId: vi.fn(), + updateAgents: vi.fn(), + findById: vi.fn(), + updateSubscriptionStatus: vi.fn(), + }, +})); + +vi.mock("@calcom/lib/server/repository/agent", () => ({ + AgentRepository: { + findByIdWithUserAccess: vi.fn(), + findByRetellAgentIdWithUserAccess: 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(), +})); + +vi.mock("@calcom/app-store/stripepayment/lib/utils", () => ({ + getPhoneNumberMonthlyPriceId: vi.fn(), +})); + +vi.mock("@calcom/features/ee/payments/server/stripe", () => ({ + default: { + checkout: { + sessions: { + create: vi.fn(), + }, + }, + subscriptions: { + cancel: vi.fn(), + }, + }, +})); + +vi.mock("@calcom/features/ee/billing/credit-service", () => ({ + CreditService: vi.fn().mockImplementation(() => ({ + getAllCredits: vi.fn(), + })), +})); + +vi.mock("@calcom/lib/checkRateLimitAndThrowError", () => ({ + checkRateLimitAndThrowError: vi.fn(), +})); + +// Mock Prisma client with transaction support +vi.mock("@calcom/prisma", () => ({ + default: { + $transaction: vi.fn(), + calAiPhoneNumber: { + create: vi.fn(), + }, + }, +})); + +describe("RetellAIService", () => { + let service: RetellAIService; + let mockRepository: RetellAIRepository & { [K in keyof RetellAIRepository]: vi.Mock }; + let mockTransaction: vi.Mock; + + beforeEach(async () => { + vi.clearAllMocks(); + const repository = { + createLLM: vi.fn(), + getLLM: vi.fn(), + updateLLM: vi.fn(), + deleteLLM: vi.fn(), + createAgent: vi.fn(), + getAgent: vi.fn(), + updateAgent: vi.fn(), + deleteAgent: vi.fn(), + createPhoneNumber: vi.fn(), + importPhoneNumber: vi.fn(), + deletePhoneNumber: vi.fn(), + getPhoneNumber: vi.fn(), + updatePhoneNumber: vi.fn(), + createPhoneCall: vi.fn(), + }; + mockRepository = repository as unknown as RetellAIRepository; + + // Get reference to the mocked prisma and its transaction method + const prisma = (await import("@calcom/prisma")).default; + mockTransaction = prisma.$transaction as vi.Mock; + + // Reset transaction mock to simulate successful transaction by default + mockTransaction.mockImplementation(async (callback) => { + const mockTx = { + calAiPhoneNumber: { + create: vi.fn().mockResolvedValue({}), + }, + }; + return callback(mockTx); + }); + + service = new RetellAIService(mockRepository); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("setupAIConfiguration", () => { + it("should create LLM and agent with minimal configuration", async () => { + const mockLLM = { llm_id: "llm-123" }; + const mockAgent = { agent_id: "agent-123" }; + mockRepository.createLLM.mockResolvedValue(mockLLM); + mockRepository.createAgent.mockResolvedValue(mockAgent); + + const result = await service.setupAIConfiguration({}); + + expect(result).toEqual({ llmId: "llm-123", agentId: "agent-123" }); + expect(mockRepository.createLLM).toHaveBeenCalledWith( + expect.objectContaining({ + general_tools: expect.arrayContaining([ + { + type: "end_call", + name: "end_call", + description: expect.any(String), + }, + ]), + }) + ); + }); + + it("should include Cal.com tools when API key and eventTypeId are provided", async () => { + const mockLLM = { llm_id: "llm-123" }; + const mockAgent = { agent_id: "agent-123" }; + + mockRepository.createLLM.mockResolvedValue(mockLLM); + mockRepository.createAgent.mockResolvedValue(mockAgent); + + await service.setupAIConfiguration({ + calApiKey: "cal-key", + eventTypeId: 123, + timeZone: "UTC", + }); + + expect(mockRepository.createLLM).toHaveBeenCalledWith( + expect.objectContaining({ + general_tools: expect.arrayContaining([ + expect.objectContaining({ type: "check_availability_cal" }), + expect.objectContaining({ type: "book_appointment_cal" }), + ]), + }) + ); + }); + }); + + describe("deleteAIConfiguration", () => { + it("should handle successful deletion of both LLM and agent", async () => { + mockRepository.deleteAgent.mockResolvedValue(undefined); + mockRepository.deleteLLM.mockResolvedValue(undefined); + + const result = await service.deleteAIConfiguration({ + llmId: "llm-123", + agentId: "agent-123", + }); + + expect(result).toEqual({ + success: true, + errors: [], + deleted: { llm: true, agent: true }, + }); + }); + + it("should handle 404 errors gracefully", async () => { + mockRepository.deleteAgent.mockRejectedValue(new RetellAIError("Agent not found", "404")); + mockRepository.deleteLLM.mockRejectedValue(new RetellAIError("LLM not found", "404")); + + const result = await service.deleteAIConfiguration({ + llmId: "llm-123", + agentId: "agent-123", + }); + + expect(result).toEqual({ + success: true, + errors: [], + deleted: { llm: true, agent: true }, + }); + }); + + it("should handle partial deletion failure", async () => { + mockRepository.deleteAgent.mockResolvedValue(undefined); + mockRepository.deleteLLM.mockRejectedValue(new Error("Network error")); + + const result = await service.deleteAIConfiguration({ + llmId: "llm-123", + agentId: "agent-123", + }); + + expect(result).toEqual({ + success: false, + errors: ["Failed to delete LLM: Error: Network error"], + deleted: { llm: false, agent: true }, + }); + }); + }); + + describe("deletePhoneNumber", () => { + it("should throw error if phone number is active", async () => { + const { PhoneNumberRepository } = await import("@calcom/lib/server/repository/phoneNumber"); + (PhoneNumberRepository.findMinimalPhoneNumber as any).mockResolvedValue({ + subscriptionStatus: PhoneNumberSubscriptionStatus.ACTIVE, + }); + + await expect( + service.deletePhoneNumber({ + phoneNumber: "+1234567890", + userId: 1, + deleteFromDB: true, + }) + ).rejects.toThrow("Phone number is still active"); + }); + + it("should throw error if phone number is cancelled", async () => { + const { PhoneNumberRepository } = await import("@calcom/lib/server/repository/phoneNumber"); + (PhoneNumberRepository.findMinimalPhoneNumber as any).mockResolvedValue({ + subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED, + }); + + await expect( + service.deletePhoneNumber({ + phoneNumber: "+1234567890", + userId: 1, + deleteFromDB: true, + }) + ).rejects.toThrow("Phone number is already cancelled"); + }); + + it("should delete from both DB and provider when deleteFromDB is true", async () => { + const { PhoneNumberRepository } = await import("@calcom/lib/server/repository/phoneNumber"); + (PhoneNumberRepository.findMinimalPhoneNumber as any).mockResolvedValue({ + subscriptionStatus: PhoneNumberSubscriptionStatus.INCOMPLETE, + }); + + await service.deletePhoneNumber({ + phoneNumber: "+1234567890", + userId: 1, + deleteFromDB: true, + }); + + expect(PhoneNumberRepository.deletePhoneNumber).toHaveBeenCalled(); + expect(mockRepository.deletePhoneNumber).toHaveBeenCalled(); + }); + }); + + describe("importPhoneNumber", () => { + it("should import phone number and create DB record using transaction", async () => { + const mockImportedNumber = { phone_number: "+1234567890" }; + mockRepository.importPhoneNumber.mockResolvedValue(mockImportedNumber); + + const result = await service.importPhoneNumber({ + phone_number: "+1234567890", + termination_uri: "https://example.com", + sip_trunk_auth_username: "user", + sip_trunk_auth_password: "pass", + userId: 1, + }); + + expect(result).toEqual(mockImportedNumber); + expect(mockTransaction).toHaveBeenCalled(); + expect(mockRepository.importPhoneNumber).toHaveBeenCalledWith({ + phone_number: "+1234567890", + termination_uri: "https://example.com", + sip_trunk_auth_username: "user", + sip_trunk_auth_password: "pass", + nickname: undefined, + }); + }); + + it("should import phone number and assign to agent if agentId provided", async () => { + const mockImportedNumber = { phone_number: "+1234567890" }; + mockRepository.importPhoneNumber.mockResolvedValue(mockImportedNumber); + const { AgentRepository } = await import("@calcom/lib/server/repository/agent"); + + (AgentRepository.findByIdWithUserAccess as any).mockResolvedValue({ + id: "agent-123", + name: "Test Agent", + retellAgentId: "retell-agent-456", + }); + + const result = await service.importPhoneNumber({ + phone_number: "+1234567890", + termination_uri: "https://example.com", + sip_trunk_auth_username: "user", + sip_trunk_auth_password: "pass", + userId: 1, + agentId: "agent-123", + }); + + expect(result).toEqual(mockImportedNumber); + expect(AgentRepository.findByIdWithUserAccess).toHaveBeenCalledWith({ + agentId: "agent-123", + userId: 1, + }); + expect(mockTransaction).toHaveBeenCalled(); + expect(mockRepository.updatePhoneNumber).toHaveBeenCalledWith("+1234567890", { + outbound_agent_id: "retell-agent-456", + }); + }); + + it("should throw error when agent not found during import", async () => { + const { AgentRepository } = await import("@calcom/lib/server/repository/agent"); + + (AgentRepository.findByIdWithUserAccess as any).mockResolvedValue(null); + + await expect( + service.importPhoneNumber({ + phone_number: "+1234567890", + termination_uri: "https://example.com", + userId: 1, + agentId: "invalid-agent", + }) + ).rejects.toThrow("You don't have permission to use the selected agent."); + + // Verify that the agent permission check was called + expect(AgentRepository.findByIdWithUserAccess).toHaveBeenCalledWith({ + agentId: "invalid-agent", + userId: 1, + }); + + // Verify that no repository operations were called after the error + expect(mockRepository.importPhoneNumber).not.toHaveBeenCalled(); + expect(mockTransaction).not.toHaveBeenCalled(); + }); + + it("should handle transaction rollback when database creation fails", async () => { + const mockImportedNumber = { phone_number: "+1234567890" }; + mockRepository.importPhoneNumber.mockResolvedValue(mockImportedNumber); + mockRepository.deletePhoneNumber.mockResolvedValue(undefined); + + // Mock transaction to simulate database creation failure + mockTransaction.mockImplementation(async (callback) => { + const mockTx = { + calAiPhoneNumber: { + create: vi.fn().mockRejectedValue(new Error("Database error")), + }, + }; + return callback(mockTx); + }); + + await expect( + service.importPhoneNumber({ + phone_number: "+1234567890", + termination_uri: "https://example.com", + sip_trunk_auth_username: "user", + sip_trunk_auth_password: "pass", + userId: 1, + }) + ).rejects.toThrow("Database error"); + + // Verify that the phone number was imported from Retell + expect(mockRepository.importPhoneNumber).toHaveBeenCalled(); + + // Verify that cleanup was attempted + expect(mockRepository.deletePhoneNumber).toHaveBeenCalledWith("+1234567890"); + }); + }); + + describe("createPhoneCall", () => { + it("should create phone call with dynamic variables", async () => { + const mockCall = { call_id: "call-123" }; + mockRepository.createPhoneCall.mockResolvedValue(mockCall); + + const result = await service.createPhoneCall({ + from_number: "+1234567890", + to_number: "+0987654321", + retell_llm_dynamic_variables: { + name: "John", + email: "john@example.com", + }, + }); + + expect(result).toEqual(mockCall); + expect(mockRepository.createPhoneCall).toHaveBeenCalledWith({ + from_number: "+1234567890", + to_number: "+0987654321", + retell_llm_dynamic_variables: { + name: "John", + email: "john@example.com", + }, + }); + }); + }); + + describe("updateLLMConfiguration", () => { + it("should update LLM configuration", async () => { + const mockUpdatedLLM = { llm_id: "llm-123", general_prompt: "Updated prompt" }; + mockRepository.updateLLM.mockResolvedValue(mockUpdatedLLM); + + const result = await service.updateLLMConfiguration("llm-123", { + general_prompt: "Updated prompt", + begin_message: "Updated message", + }); + + expect(result).toEqual(mockUpdatedLLM); + expect(mockRepository.updateLLM).toHaveBeenCalledWith("llm-123", { + general_prompt: "Updated prompt", + begin_message: "Updated message", + general_tools: null, + }); + }); + }); + + describe("getLLMDetails", () => { + it("should get LLM details", async () => { + const mockLLM = { llm_id: "llm-123", general_prompt: "Test prompt" }; + mockRepository.getLLM.mockResolvedValue(mockLLM); + + const result = await service.getLLMDetails("llm-123"); + + expect(result).toEqual(mockLLM); + expect(mockRepository.getLLM).toHaveBeenCalledWith("llm-123"); + }); + }); + + describe("getAgent", () => { + it("should get agent details", async () => { + const mockAgent = { agent_id: "agent-123", agent_name: "Test Agent" }; + mockRepository.getAgent.mockResolvedValue(mockAgent); + + const result = await service.getAgent("agent-123"); + + expect(result).toEqual(mockAgent); + expect(mockRepository.getAgent).toHaveBeenCalledWith("agent-123"); + }); + }); + + describe("updateAgent", () => { + it("should update agent", async () => { + const mockUpdatedAgent = { agent_id: "agent-123", agent_name: "Updated Agent" }; + mockRepository.updateAgent.mockResolvedValue(mockUpdatedAgent); + + const result = await service.updateAgent("agent-123", { + agent_name: "Updated Agent", + voice_id: "new-voice", + }); + + expect(result).toEqual(mockUpdatedAgent); + expect(mockRepository.updateAgent).toHaveBeenCalledWith("agent-123", { + agent_name: "Updated Agent", + voice_id: "new-voice", + }); + }); + }); + + describe("createPhoneNumber", () => { + it("should create phone number", async () => { + const mockPhoneNumber = { phone_number: "+14155551234" }; + mockRepository.createPhoneNumber.mockResolvedValue(mockPhoneNumber); + + const result = await service.createPhoneNumber({ + area_code: 415, + nickname: "Test Phone", + }); + + expect(result).toEqual(mockPhoneNumber); + expect(mockRepository.createPhoneNumber).toHaveBeenCalledWith({ + area_code: 415, + nickname: "Test Phone", + }); + }); + }); + + describe("getPhoneNumber", () => { + it("should get phone number", async () => { + const mockPhoneNumber = { phone_number: "+14155551234" }; + mockRepository.getPhoneNumber.mockResolvedValue(mockPhoneNumber); + + const result = await service.getPhoneNumber("+14155551234"); + + expect(result).toEqual(mockPhoneNumber); + expect(mockRepository.getPhoneNumber).toHaveBeenCalledWith("+14155551234"); + }); + }); + + describe("updatePhoneNumber", () => { + it("should update phone number", async () => { + const mockUpdatedNumber = { phone_number: "+14155551234" }; + mockRepository.updatePhoneNumber.mockResolvedValue(mockUpdatedNumber); + + const result = await service.updatePhoneNumber("+14155551234", { + inbound_agent_id: "inbound-123", + outbound_agent_id: "outbound-123", + }); + + expect(result).toEqual(mockUpdatedNumber); + expect(mockRepository.updatePhoneNumber).toHaveBeenCalledWith("+14155551234", { + inbound_agent_id: "inbound-123", + outbound_agent_id: "outbound-123", + }); + }); + }); + + describe("generatePhoneNumberCheckoutSession", () => { + it("should generate checkout session successfully", async () => { + const { getStripeCustomerIdFromUserId } = await import("@calcom/app-store/stripepayment/lib/customer"); + const { getPhoneNumberMonthlyPriceId } = await import("@calcom/app-store/stripepayment/lib/utils"); + const stripe = (await import("@calcom/features/ee/payments/server/stripe")).default; + + (getPhoneNumberMonthlyPriceId as any).mockReturnValue("price_123"); + (getStripeCustomerIdFromUserId as any).mockResolvedValue("cus_123"); + (stripe.checkout.sessions.create as any).mockResolvedValue({ + url: "https://checkout.stripe.com/session", + }); + + const result = await service.generatePhoneNumberCheckoutSession({ + userId: 1, + teamId: 2, + agentId: "agent-123", + }); + + expect(result).toEqual({ + url: "https://checkout.stripe.com/session", + message: "Payment required to purchase phone number", + }); + }); + + it("should throw error if price ID not configured", async () => { + const { getPhoneNumberMonthlyPriceId } = await import("@calcom/app-store/stripepayment/lib/utils"); + (getPhoneNumberMonthlyPriceId as any).mockReturnValue(null); + + await expect( + service.generatePhoneNumberCheckoutSession({ + userId: 1, + }) + ).rejects.toThrow("Phone number price ID not configured"); + }); + }); + + describe("cancelPhoneNumberSubscription", () => { + it("should cancel subscription successfully", async () => { + const { PhoneNumberRepository } = await import("@calcom/lib/server/repository/phoneNumber"); + const stripe = (await import("@calcom/features/ee/payments/server/stripe")).default; + + (PhoneNumberRepository.findById as any).mockResolvedValue({ + phoneNumber: "+14155551234", + stripeSubscriptionId: "sub_123", + }); + (stripe.subscriptions.cancel as any).mockResolvedValue({}); + + const result = await service.cancelPhoneNumberSubscription({ + phoneNumberId: 1, + userId: 1, + }); + + expect(result).toEqual({ + success: true, + message: "Phone number subscription cancelled successfully.", + }); + expect(PhoneNumberRepository.updateSubscriptionStatus).toHaveBeenCalledWith({ + id: 1, + subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED, + disconnectOutboundAgent: true, + }); + }); + }); + + describe("updatePhoneNumberWithAgents", () => { + it("should update phone number with agents", async () => { + const { PhoneNumberRepository } = await import("@calcom/lib/server/repository/phoneNumber"); + const { AgentRepository } = await import("@calcom/lib/server/repository/agent"); + + (PhoneNumberRepository.findByPhoneNumberAndUserId as any).mockResolvedValue({ + id: 1, + phoneNumber: "+14155551234", + }); + (AgentRepository.findByRetellAgentIdWithUserAccess as any).mockResolvedValue({ + id: "agent-123", + }); + mockRepository.getPhoneNumber.mockResolvedValue({ phone_number: "+14155551234" }); + + const result = await service.updatePhoneNumberWithAgents({ + phoneNumber: "+14155551234", + userId: 1, + inboundAgentId: "inbound-123", + outboundAgentId: "outbound-123", + }); + + expect(result).toEqual({ message: "Phone number updated successfully" }); + expect(mockRepository.updatePhoneNumber).toHaveBeenCalled(); + expect(PhoneNumberRepository.updateAgents).toHaveBeenCalled(); + }); + }); + + describe("listAgents", () => { + it("should list agents with user access", async () => { + const { AgentRepository } = await import("@calcom/lib/server/repository/agent"); + const mockAgents = [ + { + id: "1", + name: "Agent 1", + retellAgentId: "retell-1", + enabled: true, + userId: 1, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + (AgentRepository.findManyWithUserAccess as any).mockResolvedValue(mockAgents); + + const result = await service.listAgents({ + userId: 1, + scope: "all", + }); + + expect(result.totalCount).toBe(1); + expect(result.filtered).toHaveLength(1); + expect(AgentRepository.findManyWithUserAccess).toHaveBeenCalledWith({ + userId: 1, + teamId: undefined, + scope: "all", + }); + }); + }); + + describe("createAgent", () => { + it("should create agent successfully", async () => { + const { AgentRepository } = await import("@calcom/lib/server/repository/agent"); + + mockRepository.createLLM.mockResolvedValue({ llm_id: "llm-123" }); + mockRepository.createAgent.mockResolvedValue({ agent_id: "agent-123" }); + (AgentRepository.create as any).mockResolvedValue({ + id: "db-agent-123", + retellAgentId: "agent-123", + }); + + const result = await service.createAgent({ + name: "Test Agent", + userId: 1, + userTimeZone: "America/New_York", + }); + + expect(result).toEqual({ + id: "db-agent-123", + retellAgentId: "agent-123", + message: "Agent created successfully", + }); + }); + }); + + describe("deleteAgent", () => { + it("should delete agent successfully", async () => { + const { AgentRepository } = await import("@calcom/lib/server/repository/agent"); + + (AgentRepository.findByIdWithAdminAccess as any).mockResolvedValue({ + id: "1", + retellAgentId: "agent-123", + }); + mockRepository.getAgent.mockResolvedValue({ + agent_id: "agent-123", + response_engine: { type: "retell-llm", llm_id: "llm-123" }, + }); + + const result = await service.deleteAgent({ + id: "1", + userId: 1, + }); + + expect(result).toEqual({ message: "Agent deleted successfully" }); + expect(AgentRepository.delete).toHaveBeenCalledWith({ id: "1" }); + }); + + it("should delete agent successfully with teamId", async () => { + const { AgentRepository } = await import("@calcom/lib/server/repository/agent"); + + (AgentRepository.findByIdWithAdminAccess as any).mockResolvedValue({ + id: "1", + retellAgentId: "agent-123", + }); + mockRepository.getAgent.mockResolvedValue({ + agent_id: "agent-123", + response_engine: { type: "retell-llm", llm_id: "llm-123" }, + }); + + const result = await service.deleteAgent({ + id: "1", + userId: 1, + teamId: 5, + }); + + expect(result).toEqual({ message: "Agent deleted successfully" }); + expect(AgentRepository.findByIdWithAdminAccess).toHaveBeenCalledWith({ id: "1", userId: 1 }); + expect(AgentRepository.delete).toHaveBeenCalledWith({ id: "1" }); + }); + }); + + describe("createTestCall", () => { + it("should create test call successfully with sufficient credits", async () => { + const { CreditService } = await import("@calcom/features/ee/billing/credit-service"); + const { checkRateLimitAndThrowError } = await import("@calcom/lib/checkRateLimitAndThrowError"); + const { AgentRepository } = await import("@calcom/lib/server/repository/agent"); + + // Mock credit service to return sufficient credits + const mockGetAllCredits = vi.fn().mockResolvedValue({ + totalRemainingMonthlyCredits: 10, + additionalCredits: 5, + }); + (CreditService as any).mockImplementation(() => ({ + getAllCredits: mockGetAllCredits, + })); + + (checkRateLimitAndThrowError as any).mockResolvedValue(undefined); + (AgentRepository.findByIdWithCallAccess as any).mockResolvedValue({ + id: "1", + outboundPhoneNumbers: [{ phoneNumber: "+14155551234" }], + }); + mockRepository.createPhoneCall.mockResolvedValue({ + call_id: "call-123", + call_status: "initiated", + }); + + const result = await service.createTestCall({ + agentId: "1", + phoneNumber: "+14155555678", + userId: 1, + teamId: 2, + }); + + expect(mockGetAllCredits).toHaveBeenCalledWith({ + userId: 1, + teamId: 2, + }); + expect(checkRateLimitAndThrowError).toHaveBeenCalledWith({ + rateLimitingType: "core", + identifier: "test-call:1", + }); + expect(result).toEqual({ + callId: "call-123", + status: "initiated", + message: "Call initiated to +14155555678 with call_id call-123", + }); + }); + + it("should throw error if insufficient credits", async () => { + const { CreditService } = await import("@calcom/features/ee/billing/credit-service"); + + // Mock credit service to return insufficient credits + const mockGetAllCredits = vi.fn().mockResolvedValue({ + totalRemainingMonthlyCredits: 2, + additionalCredits: 1, + }); + (CreditService as any).mockImplementation(() => ({ + getAllCredits: mockGetAllCredits, + })); + + await expect( + service.createTestCall({ + agentId: "1", + phoneNumber: "+14155555678", + userId: 1, + }) + ).rejects.toThrow( + "Insufficient credits to make test call. Need 5 credits, have 3. Please purchase more credits." + ); + + expect(mockGetAllCredits).toHaveBeenCalledWith({ + userId: 1, + teamId: undefined, + }); + }); + + it("should handle null/undefined credits gracefully", async () => { + const { CreditService } = await import("@calcom/features/ee/billing/credit-service"); + + // Mock credit service to return null credits + const mockGetAllCredits = vi.fn().mockResolvedValue(null); + (CreditService as any).mockImplementation(() => ({ + getAllCredits: mockGetAllCredits, + })); + + await expect( + service.createTestCall({ + agentId: "1", + phoneNumber: "+14155555678", + userId: 1, + }) + ).rejects.toThrow( + "Insufficient credits to make test call. Need 5 credits, have 0. Please purchase more credits." + ); + }); + + it("should throw error if no phone number provided", async () => { + const { CreditService } = await import("@calcom/features/ee/billing/credit-service"); + const { checkRateLimitAndThrowError } = await import("@calcom/lib/checkRateLimitAndThrowError"); + + // Mock sufficient credits to get past credit check + const mockGetAllCredits = vi.fn().mockResolvedValue({ + totalRemainingMonthlyCredits: 10, + additionalCredits: 0, + }); + (CreditService as any).mockImplementation(() => ({ + getAllCredits: mockGetAllCredits, + })); + + (checkRateLimitAndThrowError as any).mockResolvedValue(undefined); + + await expect( + service.createTestCall({ + agentId: "1", + userId: 1, + }) + ).rejects.toThrow("No phone number provided for test call."); + }); + + 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 { AgentRepository } = await import("@calcom/lib/server/repository/agent"); + + // Mock sufficient credits + const mockGetAllCredits = vi.fn().mockResolvedValue({ + totalRemainingMonthlyCredits: 10, + additionalCredits: 0, + }); + (CreditService as any).mockImplementation(() => ({ + getAllCredits: mockGetAllCredits, + })); + + (checkRateLimitAndThrowError as any).mockResolvedValue(undefined); + (AgentRepository.findByIdWithCallAccess as any).mockResolvedValue(null); + + await expect( + service.createTestCall({ + agentId: "1", + phoneNumber: "+14155555678", + userId: 1, + }) + ).rejects.toThrow("Agent not found or you don't have permission to use it."); + }); + + it("should throw error if agent has no phone numbers", async () => { + const { CreditService } = await import("@calcom/features/ee/billing/credit-service"); + const { checkRateLimitAndThrowError } = await import("@calcom/lib/checkRateLimitAndThrowError"); + const { AgentRepository } = await import("@calcom/lib/server/repository/agent"); + + // Mock sufficient credits + const mockGetAllCredits = vi.fn().mockResolvedValue({ + totalRemainingMonthlyCredits: 10, + additionalCredits: 0, + }); + (CreditService as any).mockImplementation(() => ({ + getAllCredits: mockGetAllCredits, + })); + + (checkRateLimitAndThrowError as any).mockResolvedValue(undefined); + (AgentRepository.findByIdWithCallAccess as any).mockResolvedValue({ + id: "1", + outboundPhoneNumbers: [], + }); + + await expect( + service.createTestCall({ + agentId: "1", + phoneNumber: "+14155555678", + userId: 1, + }) + ).rejects.toThrow("Agent must have a phone number assigned to make calls."); + }); + }); +}); diff --git a/packages/features/ee/cal-ai-phone/providers/retell-ai/service.ts b/packages/features/ee/cal-ai-phone/providers/retell-ai/service.ts new file mode 100644 index 00000000000000..2d6080b3954366 --- /dev/null +++ b/packages/features/ee/cal-ai-phone/providers/retell-ai/service.ts @@ -0,0 +1,857 @@ +import { getStripeCustomerIdFromUserId } from "@calcom/app-store/stripepayment/lib/customer"; +import { getPhoneNumberMonthlyPriceId } from "@calcom/app-store/stripepayment/lib/utils"; +import stripe from "@calcom/features/ee/payments/server/stripe"; +import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; +import { WEBAPP_URL, IS_PRODUCTION } from "@calcom/lib/constants"; +import prisma from "@calcom/prisma"; +import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums"; + +import { TRPCError } from "@trpc/server"; + +import type { + AIPhoneServiceUpdateModelParams, + AIPhoneServiceCreatePhoneNumberParams, + AIPhoneServiceImportPhoneNumberParams, +} from "../../interfaces/ai-phone-service.interface"; +import { DEFAULT_BEGIN_MESSAGE, DEFAULT_PROMPT_VALUE } from "../../promptTemplates"; +import { RetellAIError } from "./errors"; +import { RetellServiceMapper } from "./service-mappers"; +import type { + RetellLLM, + RetellCall, + RetellAgent, + RetellPhoneNumber, + RetellDynamicVariables, + AIConfigurationSetup, + AIConfigurationDeletion, + DeletionResult, + RetellAIRepository, + RetellLLMGeneralTools, + Language, +} from "./types"; +import { getLlmId } from "./types"; + +export class RetellAIService { + constructor(private repository: RetellAIRepository) {} + + async setupAIConfiguration(config: AIConfigurationSetup): Promise<{ llmId: string; agentId: string }> { + const generalTools = RetellServiceMapper.buildGeneralTools(config); + + const llmRequest = RetellServiceMapper.mapToCreateLLMRequest( + { + ...config, + generalPrompt: config.generalPrompt || DEFAULT_PROMPT_VALUE, + beginMessage: config.beginMessage || DEFAULT_BEGIN_MESSAGE, + }, + generalTools + ); + const llm = await this.repository.createLLM(llmRequest); + + const agentRequest = RetellServiceMapper.mapToCreateAgentRequest(llm.llm_id, config.eventTypeId); + const agent = await this.repository.createAgent(agentRequest); + + return { llmId: llm.llm_id, agentId: agent.agent_id }; + } + + async importPhoneNumber(data: AIPhoneServiceImportPhoneNumberParams): Promise { + const { userId, agentId, teamId, ...rest } = data; + const { AgentRepository } = await import("@calcom/lib/server/repository/agent"); + + // Pre-check team permissions outside transaction + if (teamId) { + const canManage = await AgentRepository.canManageTeamResources({ + userId, + teamId, + }); + if (!canManage) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You don't have permission to import phone numbers for this team.", + }); + } + } + + let agent = null; + if (agentId) { + agent = await AgentRepository.findByIdWithUserAccess({ + agentId, + userId, + }); + + if (!agent) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You don't have permission to use the selected agent.", + }); + } + } + + return await prisma.$transaction(async (tx) => { + let importedPhoneNumber: RetellPhoneNumber | undefined = undefined; + + try { + // Step 1: Import phone number in Retell + importedPhoneNumber = await this.repository.importPhoneNumber({ + phone_number: rest.phone_number, + termination_uri: rest.termination_uri, + sip_trunk_auth_username: rest.sip_trunk_auth_username, + sip_trunk_auth_password: rest.sip_trunk_auth_password, + nickname: rest.nickname, + }); + + // Step 2: Create phone number record in database + await tx.calAiPhoneNumber.create({ + data: { + phoneNumber: importedPhoneNumber.phone_number, + userId, + provider: "Custom telephony", + teamId, + outboundAgentId: agent?.id || null, + }, + }); + + // Step 3: If agent is provided, update phone number in Retell + if (agent) { + try { + await this.repository.updatePhoneNumber(importedPhoneNumber.phone_number, { + outbound_agent_id: agent.retellAgentId, + }); + } catch (retellError) { + console.error("Failed to update phone number in Retell:", retellError); + // Throw to trigger transaction rollback + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to configure phone number with agent in Retell service.", + }); + } + } + + return importedPhoneNumber; + } catch (error) { + // If we have an imported phone number but something else failed, + // try to clean up the Retell phone number + if (importedPhoneNumber?.phone_number) { + try { + await this.repository.deletePhoneNumber(importedPhoneNumber.phone_number); + } catch (cleanupError) { + console.error("Failed to cleanup Retell phone number:", cleanupError); + } + } + + // Re-throw the original error to trigger transaction rollback + if (error instanceof TRPCError) { + throw error; + } + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: error instanceof Error ? error.message : "Failed to import phone number", + }); + } + }); + } + + async deleteAIConfiguration(config: AIConfigurationDeletion): Promise { + const result: DeletionResult = { + success: true, + errors: [], + deleted: { + llm: false, + agent: false, + }, + }; + + // Delete agent first (depends on LLM) + if (config.agentId) { + try { + await this.repository.deleteAgent(config.agentId); + result.deleted.agent = true; + } catch (error) { + const errorMessage = + error instanceof RetellAIError ? error.message : `Failed to delete agent: ${error}`; + result.errors.push(errorMessage); + result.success = false; + + // If it's a "not found" error, consider it successful + if (errorMessage.includes("not found") || errorMessage.includes("404")) { + result.deleted.agent = true; + result.success = true; + result.errors.pop(); // Remove the error + } + } + } else { + result.deleted.agent = true; + } + + // Delete LLM + if (config.llmId) { + try { + await this.repository.deleteLLM(config.llmId); + result.deleted.llm = true; + } catch (error) { + const errorMessage = + error instanceof RetellAIError ? error.message : `Failed to delete LLM: ${error}`; + result.errors.push(errorMessage); + result.success = false; + + // If it's a "not found" error, consider it successful + if (errorMessage.includes("not found") || errorMessage.includes("404")) { + result.deleted.llm = true; + result.success = true; + result.errors.pop(); // Remove the error + } + } + } else { + result.deleted.llm = true; // No LLM to delete + } + + return result; + } + + /** + * Update LLM configuration (for existing configurations) + */ + async updateLLMConfiguration(llmId: string, data: AIPhoneServiceUpdateModelParams): Promise { + const updateRequest = RetellServiceMapper.mapToUpdateLLMRequest(data); + return this.repository.updateLLM(llmId, updateRequest); + } + + async getLLMDetails(llmId: string): Promise { + return this.repository.getLLM(llmId); + } + + async getAgent(agentId: string): Promise { + return this.repository.getAgent(agentId); + } + + async updateAgent( + agentId: string, + data: { + agent_name?: string | null; + voice_id?: string; + language?: Language; + responsiveness?: number; + interruption_sensitivity?: number; + } + ): Promise { + const updateRequest = RetellServiceMapper.mapToUpdateAgentRequest(data); + return this.repository.updateAgent(agentId, updateRequest); + } + + async createPhoneCall(data: { + from_number: string; + to_number: string; + retell_llm_dynamic_variables?: RetellDynamicVariables; + }): Promise { + return this.repository.createPhoneCall({ + from_number: data.from_number, + to_number: data.to_number, + retell_llm_dynamic_variables: data.retell_llm_dynamic_variables, + }); + } + + async createPhoneNumber(data: AIPhoneServiceCreatePhoneNumberParams): Promise { + return this.repository.createPhoneNumber(data); + } + + async deletePhoneNumber({ + phoneNumber, + userId, + teamId, + deleteFromDB = false, + }: { + phoneNumber: string; + userId: number; + teamId?: number; + deleteFromDB: boolean; + }): Promise { + const { PhoneNumberRepository } = await import("@calcom/lib/server/repository/phoneNumber"); + + const phoneNumberToDelete = teamId + ? await PhoneNumberRepository.findByPhoneNumberAndTeamId({ + phoneNumber, + teamId, + userId, + }) + : await PhoneNumberRepository.findMinimalPhoneNumber({ + phoneNumber, + userId, + }); + + if (!phoneNumberToDelete) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Phone number not found or you don't have permission to delete it.", + }); + } + + if (phoneNumberToDelete.subscriptionStatus === PhoneNumberSubscriptionStatus.ACTIVE) { + throw new Error("Phone number is still active"); + } + if (phoneNumberToDelete.subscriptionStatus === PhoneNumberSubscriptionStatus.CANCELLED) { + throw new Error("Phone number is already cancelled"); + } + + try { + await this.repository.updatePhoneNumber(phoneNumber, { + inbound_agent_id: null, + outbound_agent_id: null, + }); + } catch (error) { + // Log the error but continue with deletion + console.error("Failed to remove agents from phone number in Retell:", error); + } + + if (deleteFromDB) { + await PhoneNumberRepository.deletePhoneNumber({ phoneNumber, userId }); + } + + await this.repository.deletePhoneNumber(phoneNumber); + } + + async getPhoneNumber(phoneNumber: string): Promise { + return this.repository.getPhoneNumber(phoneNumber); + } + + async updatePhoneNumber( + phoneNumber: string, + data: { inbound_agent_id?: string | null; outbound_agent_id?: string | null } + ): Promise { + return this.repository.updatePhoneNumber(phoneNumber, data); + } + + async generatePhoneNumberCheckoutSession({ + userId, + teamId, + agentId, + workflowId, + }: { + userId: number; + teamId?: number; + agentId?: string | null; + workflowId?: string; + }) { + const phoneNumberPriceId = getPhoneNumberMonthlyPriceId(); + + if (!phoneNumberPriceId) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Phone number price ID not configured. Please contact support.", + }); + } + + // Get or create Stripe customer + const stripeCustomerId = await getStripeCustomerIdFromUserId(userId); + if (!stripeCustomerId) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to create Stripe customer.", + }); + } + + // Create Stripe checkout session for phone number subscription + const checkoutSession = await stripe.checkout.sessions.create({ + customer: stripeCustomerId, + mode: "subscription", + line_items: [ + { + price: phoneNumberPriceId, + quantity: 1, + }, + ], + success_url: `${WEBAPP_URL}/api/phone-numbers/subscription/success?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${WEBAPP_URL}/workflows/${workflowId}`, + allow_promotion_codes: true, + customer_update: { + address: "auto", + }, + automatic_tax: { + enabled: IS_PRODUCTION, + }, + metadata: { + userId: userId.toString(), + teamId: teamId?.toString() || "", + agentId: agentId || "", + workflowId: workflowId || "", + type: "phone_number_subscription", + }, + subscription_data: { + metadata: { + userId: userId.toString(), + teamId: teamId?.toString() || "", + agentId: agentId || "", + workflowId: workflowId || "", + type: "phone_number_subscription", + }, + }, + }); + + if (!checkoutSession.url) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to create checkout session.", + }); + } + + return { url: checkoutSession.url, message: "Payment required to purchase phone number" }; + } + + async cancelPhoneNumberSubscription({ + phoneNumberId, + userId, + teamId, + }: { + phoneNumberId: number; + userId: number; + teamId?: number; + }) { + const { PhoneNumberRepository } = await import("@calcom/lib/server/repository/phoneNumber"); + + // Find phone number with proper team authorization + const phoneNumber = teamId + ? await PhoneNumberRepository.findByIdWithTeamAccess({ + id: phoneNumberId, + teamId, + userId, + }) + : await PhoneNumberRepository.findById({ + id: phoneNumberId, + userId, + }); + + if (!phoneNumber) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Phone number not found or you don't have permission to cancel it.", + }); + } + + if (!phoneNumber.stripeSubscriptionId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Phone number doesn't have an active subscription.", + }); + } + + try { + await stripe.subscriptions.cancel(phoneNumber.stripeSubscriptionId); + + await PhoneNumberRepository.updateSubscriptionStatus({ + id: phoneNumberId, + subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED, + disconnectOutboundAgent: true, + }); + + // Delete the phone number from Retell, DB + try { + await this.repository.deletePhoneNumber(phoneNumber.phoneNumber); + } catch (error) { + console.error( + "Failed to delete phone number from AI service, but subscription was cancelled:", + error + ); + } + + return { success: true, message: "Phone number subscription cancelled successfully." }; + } catch (error) { + console.error("Error cancelling phone number subscription:", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to cancel subscription. Please try again or contact support.", + }); + } + } + + async updatePhoneNumberWithAgents({ + phoneNumber, + userId, + teamId, + inboundAgentId, + outboundAgentId, + }: { + phoneNumber: string; + userId: number; + teamId?: number; + inboundAgentId?: string | null; + outboundAgentId?: string | null; + }) { + const { PhoneNumberRepository } = await import("@calcom/lib/server/repository/phoneNumber"); + const { AgentRepository } = await import("@calcom/lib/server/repository/agent"); + + const phoneNumberRecord = teamId + ? await PhoneNumberRepository.findByPhoneNumberAndTeamId({ + phoneNumber, + teamId, + userId, + }) + : await PhoneNumberRepository.findByPhoneNumberAndUserId({ + phoneNumber, + userId, + }); + + if (!phoneNumberRecord) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Phone number not found or you don't have permission to update it.", + }); + } + + if (inboundAgentId) { + const inboundAgent = await AgentRepository.findByRetellAgentIdWithUserAccess({ + retellAgentId: inboundAgentId, + userId, + }); + + if (!inboundAgent) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You don't have permission to use the selected inbound agent.", + }); + } + + if (teamId && inboundAgent.teamId !== teamId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Selected inbound agent does not belong to the specified team.", + }); + } + } + + if (outboundAgentId) { + const outboundAgent = await AgentRepository.findByRetellAgentIdWithUserAccess({ + retellAgentId: outboundAgentId, + userId, + }); + + if (!outboundAgent) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You don't have permission to use the selected outbound agent.", + }); + } + + if (teamId && outboundAgent.teamId !== teamId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Selected outbound agent does not belong to the specified team.", + }); + } + } + + try { + await this.getPhoneNumber(phoneNumber); + + const retellUpdateData = RetellServiceMapper.mapPhoneNumberUpdateData(inboundAgentId, outboundAgentId); + + if (Object.keys(retellUpdateData).length > 0) { + await this.updatePhoneNumber(phoneNumber, retellUpdateData); + } + } catch (error: unknown) { + // Check if it's a 404 error (phone number not found in Retell) + if ((error as Error).message?.includes("404") || (error as Error).message?.includes("Not Found")) { + console.log(`Phone number ${phoneNumber} not found in Retell - updating local database only`); + } else { + console.error("Failed to update phone number in AI service:", error); + } + } + + await PhoneNumberRepository.updateAgents({ + id: phoneNumberRecord.id, + inboundRetellAgentId: inboundAgentId, + outboundRetellAgentId: outboundAgentId, + }); + + return { message: "Phone number updated successfully" }; + } + + async listAgents({ + userId, + teamId, + scope = "all", + }: { + userId: number; + teamId?: number; + scope?: "personal" | "team" | "all"; + }) { + const { AgentRepository } = await import("@calcom/lib/server/repository/agent"); + + const agents = await AgentRepository.findManyWithUserAccess({ + userId, + teamId, + scope, + }); + + const formattedAgents = agents.map((agent) => RetellServiceMapper.formatAgentForList(agent)); + + return { + totalCount: formattedAgents.length, + filtered: formattedAgents, + }; + } + + async getAgentWithDetails({ id, userId, teamId }: { id: string; userId: number; teamId?: number }) { + const { AgentRepository } = await import("@calcom/lib/server/repository/agent"); + + const agent = await AgentRepository.findByIdWithUserAccessAndDetails({ + id, + userId, + teamId, + }); + + if (!agent) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Agent not found or you don't have permission to view it.", + }); + } + + const retellAgent = await this.getAgent(agent.retellAgentId); + const llmId = getLlmId(retellAgent); + + if (!llmId) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Agent does not have an LLM configured.", + }); + } + + const llmDetails = await this.getLLMDetails(llmId); + + return RetellServiceMapper.formatAgentDetails(agent, retellAgent, llmDetails); + } + + async createAgent({ + name: _name, + userId, + teamId, + workflowStepId, + generalPrompt, + beginMessage, + generalTools, + userTimeZone, + }: { + name?: string; + userId: number; + teamId?: number; + workflowStepId?: number; + generalPrompt?: string; + beginMessage?: string; + generalTools?: RetellLLMGeneralTools; + userTimeZone: string; + }) { + const { AgentRepository } = await import("@calcom/lib/server/repository/agent"); + + const agentName = _name || `Agent - ${userId} ${Math.random().toString(36).substring(2, 15)}`; + + if (teamId) { + const canManage = await AgentRepository.canManageTeamResources({ + userId, + teamId, + }); + if (!canManage) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You don't have permission to create agents for this team.", + }); + } + } + + const llmConfig = await this.setupAIConfiguration({ + calApiKey: undefined, + timeZone: userTimeZone, + eventTypeId: undefined, + generalPrompt, + beginMessage, + generalTools, + }); + + const agent = await AgentRepository.create({ + name: agentName, + retellAgentId: llmConfig.agentId, + userId, + teamId, + }); + + if (workflowStepId) { + await AgentRepository.linkToWorkflowStep({ + workflowStepId, + agentId: agent.id, + }); + } + + return { + id: agent.id, + retellAgentId: agent.retellAgentId, + message: "Agent created successfully", + }; + } + + async updateAgentConfiguration({ + id, + userId, + name, + generalPrompt, + beginMessage, + generalTools, + voiceId, + }: { + id: string; + userId: number; + name?: string; + generalPrompt?: string | null; + beginMessage?: string | null; + generalTools?: RetellLLMGeneralTools; + voiceId?: string; + }) { + const { AgentRepository } = await import("@calcom/lib/server/repository/agent"); + + const agent = await AgentRepository.findByIdWithAdminAccess({ + id, + userId, + }); + + if (!agent) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Agent not found or you don't have permission to update it.", + }); + } + + const hasRetellUpdates = + generalPrompt !== undefined || + beginMessage !== undefined || + generalTools !== undefined || + voiceId !== undefined; + + if (hasRetellUpdates) { + const retellAgent = await this.getAgent(agent.retellAgentId); + const llmId = getLlmId(retellAgent); + + if ( + llmId && + (generalPrompt !== undefined || beginMessage !== undefined || generalTools !== undefined) + ) { + const llmUpdateData = RetellServiceMapper.extractLLMUpdateData( + generalPrompt, + beginMessage, + generalTools + ); + await this.updateLLMConfiguration(llmId, llmUpdateData); + } + + if (voiceId) { + await this.updateAgent(agent.retellAgentId, { + voice_id: voiceId, + }); + } + } + + return { message: "Agent updated successfully" }; + } + + async deleteAgent({ id, userId, teamId }: { id: string; userId: number; teamId?: number }) { + const { AgentRepository } = await import("@calcom/lib/server/repository/agent"); + + const agent = await AgentRepository.findByIdWithAdminAccess({ + id, + userId, + }); + + if (!agent) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Agent not found or you don't have permission to delete it.", + }); + } + + try { + const retellAgent = await this.getAgent(agent.retellAgentId); + const llmId = getLlmId(retellAgent); + + await this.deleteAIConfiguration({ + agentId: agent.retellAgentId, + llmId: llmId || undefined, + }); + } catch (error) { + console.error("Failed to delete from Retell:", error); + } + + await AgentRepository.delete({ id }); + + return { message: "Agent deleted successfully" }; + } + + async createTestCall({ + agentId, + phoneNumber, + userId, + teamId, + }: { + agentId: string; + phoneNumber?: string; + userId: number; + teamId?: number; + }) { + const { CreditService } = await import("@calcom/features/ee/billing/credit-service"); + const creditService = new CreditService(); + const credits = await creditService.getAllCredits({ + userId, + teamId, + }); + + const availableCredits = (credits?.totalRemainingMonthlyCredits || 0) + (credits?.additionalCredits || 0); + const requiredCredits = 5; + + if (availableCredits < requiredCredits) { + throw new Error( + `Insufficient credits to make test call. Need ${requiredCredits} credits, have ${availableCredits}. Please purchase more credits.` + ); + } + + await checkRateLimitAndThrowError({ + rateLimitingType: "core", + identifier: `test-call:${userId}`, + }); + + const { AgentRepository } = await import("@calcom/lib/server/repository/agent"); + + const toNumber = phoneNumber; + if (!toNumber) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No phone number provided for test call.", + }); + } + + const agent = await AgentRepository.findByIdWithCallAccess({ + id: agentId, + userId, + }); + + if (!agent) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Agent not found or you don't have permission to use it.", + }); + } + + const agentPhoneNumber = agent.outboundPhoneNumbers?.[0]?.phoneNumber; + + if (!agentPhoneNumber) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Agent must have a phone number assigned to make calls.", + }); + } + + const call = await this.createPhoneCall({ + from_number: agentPhoneNumber, + to_number: toNumber, + }); + + return { + callId: call.call_id, + status: call.call_status, + message: `Call initiated to ${toNumber} with call_id ${call.call_id}`, + }; + } +} diff --git a/packages/features/ee/cal-ai-phone/providers/retell-ai/types.ts b/packages/features/ee/cal-ai-phone/providers/retell-ai/types.ts new file mode 100644 index 00000000000000..c77025c1277dfd --- /dev/null +++ b/packages/features/ee/cal-ai-phone/providers/retell-ai/types.ts @@ -0,0 +1,163 @@ +import type { Retell } from "retell-sdk"; + +import type { AgentRepository } from "@calcom/lib/server/repository/agent"; + +export type RetellLLM = Retell.LlmResponse; +export type RetellPhoneNumber = Retell.PhoneNumberResponse; +export type RetellCall = Retell.PhoneCallResponse; +export type RetellDynamicVariables = { [key: string]: unknown }; + +export type RetellAgent = Retell.AgentResponse; + +export type RetellAgentWithRetellLm = Retell.AgentResponse & { + response_engine: Retell.AgentResponse.ResponseEngineRetellLm; +}; + +export type RetellAgentWithCustomLm = Retell.AgentResponse & { + response_engine: Retell.AgentResponse.ResponseEngineCustomLm; +}; + +export type RetellAgentWithConversationFlow = Retell.AgentResponse & { + response_engine: Retell.AgentResponse.ResponseEngineConversationFlow; +}; + +// Type guards for safe access to response engine properties +export function isRetellLmAgent(agent: RetellAgent): agent is RetellAgentWithRetellLm { + return agent.response_engine.type === "retell-llm"; +} + +export function isCustomLmAgent(agent: RetellAgent): agent is RetellAgentWithCustomLm { + return agent.response_engine.type === "custom-llm"; +} + +export function isConversationFlowAgent(agent: RetellAgent): agent is RetellAgentWithConversationFlow { + return agent.response_engine.type === "conversation-flow"; +} + +export function getLlmId(agent: RetellAgent): string | null { + if (isRetellLmAgent(agent)) { + return agent.response_engine.llm_id; + } + return null; +} + +export type Language = + | "en-US" + | "en-IN" + | "en-GB" + | "en-AU" + | "en-NZ" + | "de-DE" + | "es-ES" + | "es-419" + | "hi-IN" + | "fr-FR" + | "fr-CA" + | "ja-JP" + | "pt-PT" + | "pt-BR" + | "zh-CN" + | "ru-RU" + | "it-IT" + | "ko-KR" + | "nl-NL" + | "nl-BE" + | "pl-PL" + | "tr-TR" + | "th-TH" + | "vi-VN" + | "ro-RO" + | "bg-BG" + | "ca-ES" + | "da-DK" + | "fi-FI" + | "el-GR" + | "hu-HU" + | "id-ID" + | "no-NO" + | "sk-SK" + | "sv-SE" + | "multi"; +// Request/response types +export type CreateLLMRequest = Retell.LlmCreateParams; +export type CreatePhoneNumberParams = Retell.PhoneNumberCreateParams; +export type CreatePhoneCallParams = Retell.CallCreatePhoneCallParams; +export type UpdatePhoneNumberParams = Retell.PhoneNumberUpdateParams; +export type ImportPhoneNumberParams = Retell.PhoneNumberImportParams; +export type RetellLLMGeneralTools = Retell.LlmCreateParams["general_tools"]; +export type CreateAgentRequest = Retell.AgentCreateParams; +export type UpdateLLMRequest = Retell.LlmUpdateParams; +export type UpdateAgentRequest = Retell.AgentUpdateParams; +export type Agent = NonNullable>>; + +export type RetellAgentWithDetails = { + id: string; + name: string; + retellAgentId: string; + enabled: boolean; + userId: number; + teamId: number | null; + outboundPhoneNumbers: Agent["outboundPhoneNumbers"]; + retellData: { + agentId: RetellAgent["agent_id"]; + agentName: RetellAgent["agent_name"]; + voiceId: RetellAgent["voice_id"]; + responseEngine: RetellAgent["response_engine"]; + language: RetellAgent["language"]; + responsiveness: RetellAgent["responsiveness"]; + interruptionSensitivity: RetellAgent["interruption_sensitivity"]; + generalPrompt: RetellLLM["general_prompt"]; + beginMessage: RetellLLM["begin_message"]; + generalTools: RetellLLM["general_tools"]; + llmId: RetellLLM["llm_id"]; + }; + createdAt: Date; + updatedAt: Date; +}; + +export interface AIConfigurationSetup { + calApiKey?: string; + timeZone?: string; + eventTypeId?: number; + generalPrompt?: string; + beginMessage?: string; + generalTools?: Retell.LlmCreateParams["general_tools"]; +} + +export interface AIConfigurationDeletion { + llmId?: string; + agentId?: string; +} + +export interface DeletionResult { + success: boolean; + errors: string[]; + deleted: { + llm: boolean; + agent: boolean; + }; +} + +export interface RetellAIRepository { + // LLM operations + createLLM(data: CreateLLMRequest): Promise; + getLLM(llmId: string): Promise; + updateLLM(llmId: string, data: UpdateLLMRequest): Promise; + deleteLLM(llmId: string): Promise; + + // Agent operations + createAgent(data: CreateAgentRequest): Promise; + getAgent(agentId: string): Promise; + updateAgent(agentId: string, data: UpdateAgentRequest): Promise; + deleteAgent(agentId: string): Promise; + + // Phone number operations + createPhoneNumber(data: CreatePhoneNumberParams): Promise; + importPhoneNumber(data: ImportPhoneNumberParams): Promise; + deletePhoneNumber(phoneNumber: string): Promise; + getPhoneNumber(phoneNumber: string): Promise; + updatePhoneNumber(phoneNumber: string, data: UpdatePhoneNumberParams): Promise; + + // Call operations + createPhoneCall(data: CreatePhoneCallParams): Promise; +} diff --git a/packages/features/ee/cal-ai-phone/template-fields-map.ts b/packages/features/ee/cal-ai-phone/template-fields-map.ts index 0790a42451d931..9ac2985afd3bd0 100644 --- a/packages/features/ee/cal-ai-phone/template-fields-map.ts +++ b/packages/features/ee/cal-ai-phone/template-fields-map.ts @@ -1,7 +1,7 @@ import type { TemplateType, Fields } from "./zod-utils"; import { fieldNameEnum } from "./zod-utils"; -export const TEMPLATES_FIELDS: Record = { +export const templateFieldsMap: Record = { CHECK_IN_APPOINTMENT: [ { type: "text", diff --git a/packages/features/ee/cal-ai-phone/zod-utils.ts b/packages/features/ee/cal-ai-phone/zod-utils.ts index 571b984002df0f..531569a47febd8 100644 --- a/packages/features/ee/cal-ai-phone/zod-utils.ts +++ b/packages/features/ee/cal-ai-phone/zod-utils.ts @@ -44,6 +44,7 @@ export type TCreatePhoneCallSchema = z.infer; export const ZGetPhoneNumberSchema = z .object({ + phone_number: z.string(), agent_id: z.string().optional(), nickname: z.string(), inbound_agent_id: z.string(), @@ -172,3 +173,28 @@ export const ZGetRetellLLMSchema = z .passthrough(); export type TGetRetellLLMSchema = z.infer; + +export const ZCreatePhoneNumberResponseSchema = z.object({ + phone_number: z.string(), + phone_number_type: z.string(), + phone_number_pretty: z.string(), + inbound_agent_id: z.string().optional().nullable(), + outbound_agent_id: z.string().optional().nullable(), + inbound_agent_version: z.number().optional().nullable(), + outbound_agent_version: z.number().optional().nullable(), + area_code: z.number().optional().nullable(), + nickname: z.string(), + inbound_webhook_url: z.string().url().optional().nullable(), + last_modification_timestamp: z.number(), +}); +export type TCreatePhoneNumberResponseSchema = z.infer; + +export const ZCreateAgentResponseSchema = z.object({ + agent_id: z.string(), + agent_name: z.string(), +}); + +export type TCreateAgentResponseSchema = z.infer; + +export const ZUpdatePhoneNumberResponseSchema = ZCreatePhoneNumberResponseSchema; +export type TUpdatePhoneNumberResponseSchema = TCreatePhoneNumberResponseSchema; diff --git a/packages/features/ee/workflows/components/AgentConfigurationSheet.tsx b/packages/features/ee/workflows/components/AgentConfigurationSheet.tsx new file mode 100644 index 00000000000000..dcd4bc32667474 --- /dev/null +++ b/packages/features/ee/workflows/components/AgentConfigurationSheet.tsx @@ -0,0 +1,1028 @@ +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 { z } from "zod"; + +import { Dialog } from "@calcom/features/components/controlled-dialog"; +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 } 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 { TestAgentDialog } from "./TestAgentDialog"; + +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(), + 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"]["ai"]["get"]; + onUpdate: (data: AgentFormValues) => void; + readOnly?: boolean; + teamId?: number; + workflowId?: string; + workflowStepId?: number; +}; + +export function AgentConfigurationSheet({ + open, + onOpenChange, + agentId, + agentData, + onUpdate, + readOnly = false, + teamId, + workflowId, + workflowStepId, +}: AgentConfigurationSheetProps) { + const { t } = useLocale(); + + const utils = trpc.useUtils(); + const [activeTab, setActiveTab] = useState<"prompt" | "phoneNumber">("prompt"); + const [isBuyDialogOpen, setIsBuyDialogOpen] = useState(false); + const [isImportDialogOpen, setIsImportDialogOpen] = useState(false); + const [showAdvancedFields, setShowAdvancedFields] = useState(false); + const [isTestAgentDialogOpen, setIsTestAgentDialogOpen] = 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: "", + generalTools: [], + }, + }); + + useEffect(() => { + if (agentData?.retellData) { + const retellData = agentData.retellData; + agentForm.reset({ + generalPrompt: retellData.generalPrompt || "", + beginMessage: retellData.beginMessage || "", + numberToCall: "", + 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?: any }) => { + 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.ai.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: any) => { + showToast(t("phone_number_imported_successfully"), "success"); + setIsImportDialogOpen(false); + phoneNumberForm.reset(); + + await utils.viewer.me.get.invalidate(); + if (agentId) { + await utils.viewer.ai.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.ai.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.ai.get.invalidate({ id: agentId }); + } + }, + onError: (error: { message: string }) => { + showToast(error.message, "error"); + setNumberToDelete(null); + }, + }); + + const agentQuery = trpc.viewer.ai.get.useQuery( + { id: agentId! }, + { + enabled: !!agentId, + refetchOnMount: false, + } + ); + + const updateAgentMutation = trpc.viewer.ai.update.useMutation({ + onSuccess: () => { + if (agentId) { + agentQuery.refetch(); + } + }, + onError: (error: { message: string }) => { + showToast(error.message, "error"); + }, + }); + + const handleAgentUpdate = async (data: AgentFormValues) => { + if (!agentId) return; + + await updateAgentMutation.mutateAsync({ + id: agentId, + teamId: teamId, + ...data, + }); + + onUpdate(data); + }; + + // 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) => { + const mutationPayload = { + ...values, + workflowId: workflowId, + agentId: agentId, // Pass the agentId to the router + 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); + + // Set cursor position after the inserted variable + setTimeout(() => { + if (generalPromptRef.current) { + generalPromptRef.current.focus(); + generalPromptRef.current.setSelectionRange( + cursorPosition + variableName.length, + cursorPosition + variableName.length + ); + } + }, 0); + } + }; + + return ( + <> + + + + {t("Cal.AI Agent Configuration")} + { + setActiveTab(val as "prompt" | "phoneNumber"); + }} + value={activeTab} + options={[ + { value: "prompt", label: t("prompt") }, + { value: "phoneNumber", label: t("phone_number") }, + ]} + isFullWidth={true} + /> + + + {activeTab === "prompt" && ( +
+
+ +

{t("initial_message_description")}

+ +
+
+
+ +

{t("general_prompt_description")}

+
+
+ {!readOnly && ( + + )} +
+