feat: add web phone call for cal ai (#23527)
* feat: add web phone call for cal ai * fix: web call * refactor: improvements * chore * refactor: web call improvements * chore: error message * refactor: improvements * fix: err * refactor: feedback and improvements * fix: type errors * refactor: hasAvailable * chore: use orgid * fix: types --------- Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
co-authored by
Peer Richelsen
parent
2e96600529
commit
a8a3e48465
@@ -139,6 +139,7 @@
|
||||
"react-use-intercom": "1.5.1",
|
||||
"recoil": "^0.7.7",
|
||||
"remove-markdown": "^0.5.0",
|
||||
"retell-client-js-sdk": "^2.0.0",
|
||||
"retell-sdk": "^4.40.0",
|
||||
"rrule": "^2.7.1",
|
||||
"sanitize-html": "^2.10.0",
|
||||
|
||||
@@ -478,6 +478,24 @@
|
||||
<path d="M14 9a2 2 0 0 1-2 2H6l-4 4V4c0-1.1.9-2 2-2h8a2 2 0 0 1 2 2z" ></path>
|
||||
<path d="M18 9h2a2 2 0 0 1 2 2v11l-4-4h-6a2 2 0 0 1-2-2v-1" ></path>
|
||||
</symbol>
|
||||
<symbol class="lucide lucide-mic-off" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="mic-off">
|
||||
<line x1="2" x2="22" y1="2" y2="22" ></line>
|
||||
<path d="M18.89 13.23A7.12 7.12 0 0 0 19 12v-2" ></path>
|
||||
<path d="M5 10v2a7 7 0 0 0 12 5" ></path>
|
||||
<path d="M15 9.34V5a3 3 0 0 0-5.68-1.33" ></path>
|
||||
<path d="M9 9v3a3 3 0 0 0 5.12 2.12" ></path>
|
||||
<line x1="12" x2="12" y1="19" y2="22" ></line>
|
||||
</symbol>
|
||||
<symbol class="lucide lucide-mic" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="mic">
|
||||
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" ></path>
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2" ></path>
|
||||
<line x1="12" x2="12" y1="19" y2="22" ></line>
|
||||
</symbol>
|
||||
<symbol class="lucide lucide-monitor" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="monitor">
|
||||
<rect width="20" height="14" x="2" y="3" rx="2" ></rect>
|
||||
<line x1="8" x2="16" y1="21" y2="21" ></line>
|
||||
<line x1="12" x2="12" y1="17" y2="21" ></line>
|
||||
</symbol>
|
||||
<symbol class="lucide lucide-moon" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="moon">
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" ></path>
|
||||
</symbol>
|
||||
@@ -498,6 +516,10 @@
|
||||
<path d="M14.05 2a9 9 0 0 1 8 7.94" ></path>
|
||||
<path d="M14.05 6A5 5 0 0 1 18 10" ></path>
|
||||
</symbol>
|
||||
<symbol class="lucide lucide-phone-off" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="phone-off">
|
||||
<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91" ></path>
|
||||
<line x1="22" x2="2" y1="2" y2="22" ></line>
|
||||
</symbol>
|
||||
<symbol class="lucide lucide-phone" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="phone">
|
||||
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" ></path>
|
||||
</symbol>
|
||||
|
||||
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 53 KiB |
@@ -860,6 +860,7 @@
|
||||
"phone_number_for_ai_call": "Phone number (for our agent to call)",
|
||||
"buy_a_phone_number_or_import_one_you_already_have": "Buy a phone number or import one you already have on twilio",
|
||||
"test_agent": "Test Agent",
|
||||
"test_web_call": "Test Web Call",
|
||||
"phone_numbers": "Phone Numbers",
|
||||
"unsubscribe": "Unsubscribe",
|
||||
"delete_workflow_step": "Delete Workflow Step",
|
||||
@@ -1479,6 +1480,24 @@
|
||||
"app_removed_successfully": "App removed successfully",
|
||||
"error_removing_app": "Error removing app",
|
||||
"web_conference": "Web conference",
|
||||
"web_call": "Web call",
|
||||
"start_web_call": "Start web call",
|
||||
"end_call": "End call",
|
||||
"test_your_agent_with_web_call": "Test your agent with a web call without requiring a phone number",
|
||||
"phone_call": "Phone call",
|
||||
"connecting_to_agent": "Connecting to agent...",
|
||||
"call_active": "Call active",
|
||||
"call_ended": "Call ended",
|
||||
"call_error": "Call error",
|
||||
"ready_to_start_call": "Ready to start call",
|
||||
"failed_to_start_web_call_try_again": "Failed to start web call. Please try again.",
|
||||
"call_encountered_error_try_again": "Call encountered an error. Please try again.",
|
||||
"failed_initialize_web_call_microphone_permissions": "Failed to initialize web call. Please ensure microphone permissions are granted.",
|
||||
"start_call_to_see_conversation": "Start a call to see the conversation",
|
||||
"waiting_for_conversation": "Waiting for conversation...",
|
||||
"ai_agent": "AI Agent",
|
||||
"live": "Live",
|
||||
"connecting": "Connecting...",
|
||||
"requires_confirmation": "Requires confirmation",
|
||||
"requires_confirmation_threshold": "Requires confirmation if booked with < {{time}} $t({{unit}}_timeUnit) notice",
|
||||
"may_require_confirmation": "May require confirmation",
|
||||
@@ -1714,6 +1733,7 @@
|
||||
"transcription_enabled": "Transcriptions are enabled now",
|
||||
"transcription_stopped": "Transcriptions are stopped now",
|
||||
"download_transcript": "Download Transcript",
|
||||
"transcript": "Transcript",
|
||||
"recording_from_your_recent_call": "A recording from your recent call on {{appName}} is ready for download",
|
||||
"transcript_from_previous_call": "Transcript from your recent call on {{appName}} is ready to download. Links are valid only for 1 Hour",
|
||||
"you_can_download_transcript_from_attachments": "You can also download transcript from attachments",
|
||||
@@ -3622,6 +3642,11 @@
|
||||
"send_another_message": "Send another message",
|
||||
"sending": "Sending",
|
||||
"no_variables_found": "No variables found",
|
||||
"credits_required": "Credits Required",
|
||||
"web_call_credits_info": "Web calls consume credits. Make sure you have enough credits to start a call.",
|
||||
"web_call_no_credits": "Web calls require credits. You have 0 credits remaining.",
|
||||
"purchase_credits": "Purchase Credits",
|
||||
"credits_required_tooltip": "This action requires credits to proceed",
|
||||
"api_key_name_too_long": "Name must not exceed {{max}} characters",
|
||||
"auto_charge_for_last_minute_cancellation": "Auto charge no-show fee for last minute cancellations",
|
||||
"before_scheduled_start_time": "Before scheduled start time",
|
||||
|
||||
@@ -327,6 +327,21 @@ export interface AIPhoneServiceProvider<T extends AIPhoneServiceProviderType = A
|
||||
message: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Create a web call
|
||||
*/
|
||||
createWebCall(params: {
|
||||
agentId: string;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
timeZone: string;
|
||||
eventTypeId: number;
|
||||
}): Promise<{
|
||||
callId: string;
|
||||
accessToken: string;
|
||||
agentId: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Update tools from event type ID
|
||||
*/
|
||||
|
||||
@@ -266,6 +266,20 @@ export class RetellAIPhoneServiceProvider
|
||||
return await this.service.createTestCall(params);
|
||||
}
|
||||
|
||||
async createWebCall(params: {
|
||||
agentId: string;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
timeZone: string;
|
||||
eventTypeId: number;
|
||||
}): Promise<{
|
||||
callId: string;
|
||||
accessToken: string;
|
||||
agentId: string;
|
||||
}> {
|
||||
return await this.service.createWebCall(params);
|
||||
}
|
||||
|
||||
async updateToolsFromAgentId(
|
||||
agentId: string,
|
||||
data: { eventTypeId: number | null; timeZone: string; userId: number | null; teamId?: number | null }
|
||||
|
||||
@@ -220,6 +220,16 @@ export class RetellAIService {
|
||||
return this.callService.createTestCall(params);
|
||||
}
|
||||
|
||||
async createWebCall(params: {
|
||||
agentId: string;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
timeZone: string;
|
||||
eventTypeId: number;
|
||||
}) {
|
||||
return this.callService.createWebCall(params);
|
||||
}
|
||||
|
||||
async generatePhoneNumberCheckoutSession(params: {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
RetellAgent,
|
||||
CreatePhoneNumberParams,
|
||||
CreatePhoneCallParams,
|
||||
CreateWebCallParams,
|
||||
ImportPhoneNumberParams,
|
||||
} from "./types";
|
||||
|
||||
@@ -234,4 +235,17 @@ export class RetellSDKClient implements RetellAIRepository {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createWebCall(data: CreateWebCallParams) {
|
||||
try {
|
||||
const response = await this.client.call.createWebCall({
|
||||
agent_id: data.agentId,
|
||||
retell_llm_dynamic_variables: data.dynamicVariables,
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to create web call", { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +166,93 @@ export class CallService {
|
||||
};
|
||||
}
|
||||
|
||||
async createWebCall({
|
||||
agentId,
|
||||
userId,
|
||||
teamId,
|
||||
timeZone,
|
||||
eventTypeId,
|
||||
}: {
|
||||
agentId: string;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
timeZone: string;
|
||||
eventTypeId: number;
|
||||
}) {
|
||||
if (!agentId?.trim()) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "Agent ID is required and cannot be empty",
|
||||
});
|
||||
}
|
||||
|
||||
await this.validateCreditsForTestCall({ userId, teamId });
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: `web-call:${userId}`,
|
||||
});
|
||||
|
||||
const agent = await this.agentRepository.findByIdWithCallAccess({
|
||||
id: agentId,
|
||||
userId,
|
||||
});
|
||||
|
||||
if (!agent) {
|
||||
throw new HttpError({
|
||||
statusCode: 404,
|
||||
message: "Agent not found or you don't have permission to use it.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!agent.providerAgentId) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "Agent provider ID not found.",
|
||||
});
|
||||
}
|
||||
|
||||
const dynamicVariables = {
|
||||
EVENT_NAME: "Web Call Test with Agent",
|
||||
EVENT_DATE: "Monday, January 15, 2025",
|
||||
EVENT_TIME: "2:00 PM",
|
||||
EVENT_END_TIME: "2:30 PM",
|
||||
TIMEZONE: timeZone,
|
||||
LOCATION: "Web Call",
|
||||
ORGANIZER_NAME: "Cal.com AI Agent",
|
||||
ATTENDEE_NAME: "Test User",
|
||||
ATTENDEE_FIRST_NAME: "Test",
|
||||
ATTENDEE_LAST_NAME: "User",
|
||||
ATTENDEE_EMAIL: "testuser@example.com",
|
||||
ATTENDEE_TIMEZONE: timeZone,
|
||||
ADDITIONAL_NOTES: "This is a test web call to verify the AI phone agent",
|
||||
EVENT_START_TIME_IN_ATTENDEE_TIMEZONE: "2:00 PM",
|
||||
EVENT_END_TIME_IN_ATTENDEE_TIMEZONE: "2:30 PM",
|
||||
eventTypeId: eventTypeId.toString(),
|
||||
};
|
||||
|
||||
try {
|
||||
const webCall = await this.retellRepository.createWebCall({
|
||||
agentId: agent.providerAgentId,
|
||||
dynamicVariables,
|
||||
});
|
||||
return {
|
||||
callId: webCall.call_id,
|
||||
accessToken: webCall.access_token,
|
||||
agentId: webCall.agent_id,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to create web call in external AI service", {
|
||||
agentId: agent.providerAgentId,
|
||||
error,
|
||||
});
|
||||
throw new HttpError({
|
||||
statusCode: 500,
|
||||
message: "Failed to create web call",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async validateCreditsForTestCall({ userId, teamId }: { userId: number; teamId?: number }) {
|
||||
try {
|
||||
const { CreditService } = await import("@calcom/features/ee/billing/credit-service");
|
||||
|
||||
@@ -86,6 +86,11 @@ export type CreatePhoneCallParams = {
|
||||
toNumber: string;
|
||||
dynamicVariables?: RetellDynamicVariables;
|
||||
};
|
||||
|
||||
export type CreateWebCallParams = {
|
||||
agentId: string;
|
||||
dynamicVariables?: RetellDynamicVariables;
|
||||
};
|
||||
export type UpdatePhoneNumberParams = Retell.PhoneNumberUpdateParams;
|
||||
export type ImportPhoneNumberParams = Retell.PhoneNumberImportParams;
|
||||
export type RetellLLMGeneralTools = Retell.LlmCreateParams["general_tools"];
|
||||
@@ -171,4 +176,7 @@ export interface RetellAIRepository {
|
||||
|
||||
// Call operations
|
||||
createPhoneCall(data: CreatePhoneCallParams): Promise<RetellCall>;
|
||||
createWebCall(
|
||||
data: CreateWebCallParams
|
||||
): Promise<{ call_id: string; access_token: string; agent_id: string }>;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,8 @@ import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
import { DYNAMIC_TEXT_VARIABLES } from "../lib/constants";
|
||||
import type { FormValues } from "../pages/workflow";
|
||||
import { TestAgentDialog } from "./TestAgentDialog";
|
||||
import { TestPhoneCallDialog } from "./TestPhoneCallDialog";
|
||||
import { WebCallDialog } from "./WebCallDialog";
|
||||
|
||||
// Utility functions for prompt display
|
||||
const cleanPromptForDisplay = (prompt: string): string => {
|
||||
@@ -114,6 +115,7 @@ type AgentConfigurationSheetProps = {
|
||||
onUpdate: (data: AgentFormValues) => void;
|
||||
readOnly?: boolean;
|
||||
teamId?: number;
|
||||
isOrganization?: boolean;
|
||||
workflowId?: string;
|
||||
workflowStepId?: number;
|
||||
activeTab?: "prompt" | "phoneNumber";
|
||||
@@ -129,6 +131,7 @@ export function AgentConfigurationSheet({
|
||||
onUpdate,
|
||||
readOnly = false,
|
||||
teamId,
|
||||
isOrganization = false,
|
||||
workflowId,
|
||||
workflowStepId: _workflowStepId,
|
||||
form,
|
||||
@@ -141,6 +144,7 @@ export function AgentConfigurationSheet({
|
||||
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
|
||||
const [showAdvancedFields, setShowAdvancedFields] = useState(false);
|
||||
const [isTestAgentDialogOpen, setIsTestAgentDialogOpen] = useState(false);
|
||||
const [isWebCallDialogOpen, setIsWebCallDialogOpen] = useState(false);
|
||||
const [cancellingNumberId, setCancellingNumberId] = useState<number | null>(null);
|
||||
const [numberToDelete, setNumberToDelete] = useState<string | null>(null);
|
||||
// const [toolDialogOpen, setToolDialogOpen] = useState(false);
|
||||
@@ -593,13 +597,39 @@ export function AgentConfigurationSheet({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() => setIsTestAgentDialogOpen(true)}
|
||||
className="rounded-[10px]"
|
||||
disabled={readOnly}>
|
||||
{t("test_agent")}
|
||||
</Button>
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
color="secondary"
|
||||
className="rounded-[10px]"
|
||||
disabled={readOnly}
|
||||
EndIcon="chevron-down">
|
||||
{t("test_agent")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
StartIcon="phone"
|
||||
onClick={() => {
|
||||
setIsTestAgentDialogOpen(true);
|
||||
}}>
|
||||
{t("phone_call")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
StartIcon="monitor"
|
||||
onClick={() => {
|
||||
setIsWebCallDialogOpen(true);
|
||||
}}>
|
||||
{t("web_call")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" color="secondary" variant="icon" StartIcon="ellipsis" />
|
||||
@@ -964,7 +994,7 @@ export function AgentConfigurationSheet({
|
||||
</Dialog>
|
||||
|
||||
{agentId && (
|
||||
<TestAgentDialog
|
||||
<TestPhoneCallDialog
|
||||
open={isTestAgentDialogOpen}
|
||||
onOpenChange={setIsTestAgentDialogOpen}
|
||||
agentId={agentId}
|
||||
@@ -973,6 +1003,17 @@ export function AgentConfigurationSheet({
|
||||
/>
|
||||
)}
|
||||
|
||||
{agentId && (
|
||||
<WebCallDialog
|
||||
open={isWebCallDialogOpen}
|
||||
onOpenChange={setIsWebCallDialogOpen}
|
||||
agentId={agentId}
|
||||
teamId={teamId}
|
||||
isOrganization={isOrganization}
|
||||
form={form}
|
||||
/>
|
||||
)}
|
||||
|
||||
<UIDialog open={cancellingNumberId !== null} onOpenChange={() => setCancellingNumberId(null)}>
|
||||
<ConfirmationDialogContent
|
||||
isPending={cancelSubscriptionMutation.isPending}
|
||||
|
||||
+3
-4
@@ -13,7 +13,7 @@ import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import type { FormValues } from "../pages/workflow";
|
||||
|
||||
interface TestAgentDialogProps {
|
||||
interface TestPhoneCallDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
agentId: string;
|
||||
@@ -21,7 +21,7 @@ interface TestAgentDialogProps {
|
||||
form: UseFormReturn<FormValues>;
|
||||
}
|
||||
|
||||
export function TestAgentDialog({ open, onOpenChange, agentId, teamId, form }: TestAgentDialogProps) {
|
||||
export function TestPhoneCallDialog({ open, onOpenChange, agentId, teamId, form }: TestPhoneCallDialogProps) {
|
||||
const { t } = useLocale();
|
||||
const [testPhoneNumber, setTestPhoneNumber] = useState("");
|
||||
|
||||
@@ -58,8 +58,7 @@ export function TestAgentDialog({ open, onOpenChange, agentId, teamId, form }: T
|
||||
};
|
||||
|
||||
return (
|
||||
// Without modal={false}, the screen freezes when Sheet is open
|
||||
<Dialog open={open} onOpenChange={onOpenChange} modal={false}>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
type="creation"
|
||||
title={t("test_cal_ai_agent")}
|
||||
@@ -0,0 +1,477 @@
|
||||
import Link from "next/link";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import type { RetellWebClient } from "retell-client-js-sdk";
|
||||
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { DialogContent, DialogFooter } from "@calcom/ui/components/dialog";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
import type { FormValues } from "../pages/workflow";
|
||||
|
||||
interface WebCallDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
agentId: string;
|
||||
teamId?: number;
|
||||
isOrganization?: boolean;
|
||||
form: UseFormReturn<FormValues>;
|
||||
}
|
||||
|
||||
interface TranscriptEntry {
|
||||
speaker: "agent" | "user";
|
||||
text: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
type CallStatus = "idle" | "connecting" | "active" | "ended" | "error";
|
||||
|
||||
export function WebCallDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
agentId,
|
||||
teamId,
|
||||
isOrganization = false,
|
||||
form,
|
||||
}: WebCallDialogProps) {
|
||||
const { t } = useLocale();
|
||||
const [callStatus, setCallStatus] = useState<CallStatus>("idle");
|
||||
const [transcript, setTranscript] = useState<TranscriptEntry[]>([]);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [callDuration, setCallDuration] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const retellWebClientRef = useRef<RetellWebClient | null>(null);
|
||||
const callStartTimeRef = useRef<Date | null>(null);
|
||||
const durationIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
||||
const callStatusRef = useRef<CallStatus>("idle");
|
||||
|
||||
const { data: hasCredits, isLoading: creditsLoading } = trpc.viewer.credits.hasAvailableCredits.useQuery(
|
||||
{ teamId },
|
||||
{ enabled: open }
|
||||
);
|
||||
|
||||
const getBillingPath = () => {
|
||||
if (!teamId) return "/settings/billing";
|
||||
return isOrganization ? "/settings/organizations/billing" : `/settings/teams/${teamId}/billing`;
|
||||
};
|
||||
|
||||
const createWebCallMutation = trpc.viewer.aiVoiceAgent.createWebCall.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
try {
|
||||
await startWebCall(data.accessToken);
|
||||
} catch (error) {
|
||||
console.error("Failed to start web call:", error);
|
||||
setCallStatus("error");
|
||||
setError(t("failed_to_start_web_call_try_again"));
|
||||
}
|
||||
},
|
||||
onError: (error: { message: string }) => {
|
||||
setCallStatus("error");
|
||||
setError(error.message);
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const startWebCall = async (accessToken: string) => {
|
||||
try {
|
||||
const { RetellWebClient } = await import("retell-client-js-sdk");
|
||||
|
||||
const retellWebClient = new RetellWebClient();
|
||||
retellWebClientRef.current = retellWebClient;
|
||||
|
||||
retellWebClient.on("call_started", () => {
|
||||
callStartTimeRef.current = new Date();
|
||||
callStatusRef.current = "active";
|
||||
setCallStatus("active");
|
||||
startDurationTimer();
|
||||
});
|
||||
|
||||
retellWebClient.on("call_ended", () => {
|
||||
setCallStatus("ended");
|
||||
stopDurationTimer();
|
||||
});
|
||||
|
||||
retellWebClient.on("agent_start_talking", () => {
|
||||
console.log("Agent started talking");
|
||||
});
|
||||
|
||||
retellWebClient.on("agent_stop_talking", () => {
|
||||
console.log("Agent stopped talking");
|
||||
});
|
||||
|
||||
retellWebClient.on("update", (update: { transcript?: Array<{ role: string; content: string }> }) => {
|
||||
console.log("📝 Received update event:", update);
|
||||
|
||||
if (update.transcript && Array.isArray(update.transcript)) {
|
||||
console.log("📜 Transcript array received:", update.transcript);
|
||||
|
||||
try {
|
||||
const newEntries: TranscriptEntry[] = update.transcript
|
||||
.map((entry) => {
|
||||
if (!entry.role || !entry.content) {
|
||||
console.warn("⚠️ Invalid transcript entry:", entry);
|
||||
return null;
|
||||
}
|
||||
const mappedEntry = {
|
||||
speaker: entry.role === "agent" ? "agent" : "user",
|
||||
text: entry.content,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
console.log("✅ Mapped transcript entry:", mappedEntry);
|
||||
return mappedEntry;
|
||||
})
|
||||
.filter(Boolean) as TranscriptEntry[];
|
||||
|
||||
console.log("🔄 Setting transcript with entries:", newEntries.length, "entries");
|
||||
console.log("📋 Current transcript state has:", transcript.length, "entries");
|
||||
|
||||
setTranscript(newEntries);
|
||||
} catch (error) {
|
||||
console.error("❌ Error processing transcript update:", error);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
"🚫 No transcript data in update or transcript is not an array:",
|
||||
typeof update.transcript
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
retellWebClient.on("error", (error: Error | { message?: string }) => {
|
||||
console.error("Web call error:", error);
|
||||
setCallStatus("error");
|
||||
setError(t("call_encountered_error_try_again"));
|
||||
stopDurationTimer();
|
||||
});
|
||||
|
||||
setCallStatus("connecting");
|
||||
await retellWebClient.startCall({
|
||||
accessToken: accessToken,
|
||||
sampleRate: 24000,
|
||||
emitRawAudioSamples: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error starting web call:", error);
|
||||
setCallStatus("error");
|
||||
setError(t("failed_initialize_web_call_microphone_permissions"));
|
||||
}
|
||||
};
|
||||
|
||||
const startDurationTimer = useCallback(() => {
|
||||
if (durationIntervalRef.current) {
|
||||
clearInterval(durationIntervalRef.current);
|
||||
durationIntervalRef.current = null;
|
||||
}
|
||||
|
||||
if (!callStartTimeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
durationIntervalRef.current = setInterval(() => {
|
||||
if (callStartTimeRef.current) {
|
||||
const now = Date.now();
|
||||
const startTime = callStartTimeRef.current.getTime();
|
||||
const duration = Math.floor((now - startTime) / 1000);
|
||||
if (duration >= 0) {
|
||||
setCallDuration(duration);
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
}, []);
|
||||
|
||||
const stopDurationTimer = () => {
|
||||
if (durationIntervalRef.current) {
|
||||
clearInterval(durationIntervalRef.current);
|
||||
durationIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartCall = () => {
|
||||
const firstEventTypeId = form.getValues("activeOn")?.[0]?.value;
|
||||
if (!firstEventTypeId) {
|
||||
showToast(t("choose_at_least_one_event_type_test_call"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (agentId) {
|
||||
setError(null);
|
||||
setTranscript([]);
|
||||
setCallDuration(0);
|
||||
createWebCallMutation.mutate({
|
||||
agentId: agentId,
|
||||
teamId: teamId,
|
||||
eventTypeId: parseInt(firstEventTypeId, 10),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndCall = async () => {
|
||||
if (retellWebClientRef.current && callStatus === "active") {
|
||||
try {
|
||||
await retellWebClientRef.current.stopCall();
|
||||
setCallStatus("ended");
|
||||
stopDurationTimer();
|
||||
} catch (error) {
|
||||
console.error("Error ending call:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleMute = async () => {
|
||||
if (retellWebClientRef.current && callStatus === "active") {
|
||||
try {
|
||||
if (isMuted) {
|
||||
await retellWebClientRef.current.unmute();
|
||||
} else {
|
||||
await retellWebClientRef.current.mute();
|
||||
}
|
||||
setIsMuted(!isMuted);
|
||||
} catch (error) {
|
||||
console.error("Error toggling mute:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (callStatus) {
|
||||
case "connecting":
|
||||
return <Icon name="loader" className="h-4 w-4 animate-spin rounded-full" />;
|
||||
case "active":
|
||||
return <Icon name="phone" className="text-success h-4 w-4" />;
|
||||
case "ended":
|
||||
return <Icon name="phone-off" className="text-muted h-4 w-4" />;
|
||||
case "error":
|
||||
return <Icon name="triangle-alert" className="text-error h-4 w-4" />;
|
||||
default:
|
||||
return <Icon name="phone" className="text-subtle h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
switch (callStatus) {
|
||||
case "connecting":
|
||||
return t("connecting_to_agent");
|
||||
case "active":
|
||||
return `${t("call_active")} - ${formatDuration(callDuration)}`;
|
||||
case "ended":
|
||||
return `${t("call_ended")} - ${formatDuration(callDuration)}`;
|
||||
case "error":
|
||||
return t("call_error");
|
||||
default:
|
||||
return t("ready_to_start_call");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (transcriptEndRef.current) {
|
||||
// eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed
|
||||
transcriptEndRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [transcript]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopDurationTimer();
|
||||
if (retellWebClientRef.current && callStatus === "active") {
|
||||
try {
|
||||
retellWebClientRef.current.stopCall();
|
||||
} catch (error) {
|
||||
console.error("Error stopping call during cleanup:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [callStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
callStatusRef.current = callStatus;
|
||||
|
||||
if (callStatus === "active" && callStartTimeRef.current && !durationIntervalRef.current) {
|
||||
startDurationTimer();
|
||||
}
|
||||
}, [callStatus, startDurationTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopDurationTimer();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const resetDialogState = () => {
|
||||
setCallStatus("idle");
|
||||
callStatusRef.current = "idle";
|
||||
setTranscript([]);
|
||||
setIsMuted(false);
|
||||
setCallDuration(0);
|
||||
setError(null);
|
||||
callStartTimeRef.current = null;
|
||||
stopDurationTimer();
|
||||
retellWebClientRef.current = null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
if (retellWebClientRef.current) {
|
||||
try {
|
||||
retellWebClientRef.current.stopCall();
|
||||
} catch (e) {
|
||||
console.error("Error stopping call on close:", e);
|
||||
}
|
||||
}
|
||||
resetDialogState();
|
||||
}
|
||||
onOpenChange(open);
|
||||
}}>
|
||||
<DialogContent
|
||||
enableOverflow
|
||||
type="creation"
|
||||
title={t("web_call")}
|
||||
description={t("test_your_agent_with_web_call")}>
|
||||
{!creditsLoading && (
|
||||
<Alert
|
||||
severity="info"
|
||||
CustomIcon="info"
|
||||
title={t("credits_required")}
|
||||
message={
|
||||
hasCredits ? (
|
||||
t("web_call_credits_info")
|
||||
) : (
|
||||
<>
|
||||
{t("web_call_no_credits")}{" "}
|
||||
<Link href={getBillingPath()} className="text-semantic-info underline">
|
||||
{t("purchase_credits")}
|
||||
</Link>
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-2 flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon()}
|
||||
<span className="text-sm font-medium">{getStatusText()}</span>
|
||||
</div>
|
||||
{callStatus === "active" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-error h-2 w-2 animate-pulse rounded-full" />
|
||||
<span className="text-subtle text-xs">{t("live")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 rounded-lg border p-3 placeholder:min-h-[300px]">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium">{t("transcript")}</h4>
|
||||
{transcript.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
variant="icon"
|
||||
onClick={() => setTranscript([])}
|
||||
disabled={callStatus === "active"}>
|
||||
<Icon name="trash" className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-60 space-y-3 overflow-y-auto p-2">
|
||||
{transcript.length === 0 ? (
|
||||
<div className="flex h-40 items-center justify-center text-center">
|
||||
<div>
|
||||
<Icon name="message-circle" className="text-subtle mx-auto h-8 w-8" />
|
||||
<p className="text-subtle mt-2 text-sm">
|
||||
{callStatus === "idle"
|
||||
? t("start_call_to_see_conversation")
|
||||
: t("waiting_for_conversation")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
transcript.map((entry, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex gap-2 ${entry.speaker === "agent" ? "justify-start" : "justify-end"}`}>
|
||||
<div
|
||||
className={`max-w-[80%] rounded-lg px-3 py-2 text-sm ${
|
||||
entry.speaker === "agent"
|
||||
? "border-brand bg-brand/10 text-brand-emphasis border"
|
||||
: "border-subtle bg-subtle text-emphasis border"
|
||||
}`}>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="text-xs font-medium">
|
||||
{entry.speaker === "agent" ? t("ai_agent") : t("you")}
|
||||
</span>
|
||||
</div>
|
||||
<p>{entry.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={transcriptEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && callStatus === "error" && (
|
||||
<div className="border-error bg-error/10 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name="triangle-alert" className="text-error h-6 w-6" />
|
||||
<span className="text-error text-sm">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter showDivider className="mt-6">
|
||||
<div className="flex gap-2">
|
||||
{callStatus === "active" && (
|
||||
<Button type="button" color="secondary" onClick={handleToggleMute} variant="icon">
|
||||
<Icon name={isMuted ? "mic-off" : "mic"} className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{callStatus === "idle" ||
|
||||
callStatus === "error" ||
|
||||
callStatus === "ended" ||
|
||||
callStatus === "connecting" ? (
|
||||
<Tooltip content={!hasCredits ? t("credits_required_tooltip") : undefined}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleStartCall}
|
||||
loading={createWebCallMutation.isPending || callStatus === "connecting"}
|
||||
disabled={!hasCredits || createWebCallMutation.isPending || callStatus === "connecting"}>
|
||||
<Icon name="phone" className="mr-2 h-4 w-4" />
|
||||
{callStatus === "connecting" ? t("connecting") : t("start_web_call")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
color="destructive"
|
||||
onClick={handleEndCall}
|
||||
disabled={callStatus !== "active"}>
|
||||
<Icon name="phone-off" className="mr-2 h-4 w-4" />
|
||||
{t("end_call")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -64,8 +64,9 @@ import emailRatingTemplate from "../lib/reminders/templates/emailRatingTemplate"
|
||||
import emailReminderTemplate from "../lib/reminders/templates/emailReminderTemplate";
|
||||
import type { FormValues } from "../pages/workflow";
|
||||
import { AgentConfigurationSheet } from "./AgentConfigurationSheet";
|
||||
import { TestAgentDialog } from "./TestAgentDialog";
|
||||
import { TestPhoneCallDialog } from "./TestPhoneCallDialog";
|
||||
import { TimeTimeUnitInput } from "./TimeTimeUnitInput";
|
||||
import { WebCallDialog } from "./WebCallDialog";
|
||||
|
||||
type User = RouterOutputs["viewer"]["me"]["get"];
|
||||
|
||||
@@ -209,6 +210,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
const verifiedEmails = _verifiedEmails || [];
|
||||
const [isAdditionalInputsDialogOpen, setIsAdditionalInputsDialogOpen] = useState(false);
|
||||
const [isTestAgentDialogOpen, setIsTestAgentDialogOpen] = useState(false);
|
||||
const [isWebCallDialogOpen, setIsWebCallDialogOpen] = useState(false);
|
||||
const [isUnsubscribeDialogOpen, setIsUnsubscribeDialogOpen] = useState(false);
|
||||
const [isDeleteStepDialogOpen, setIsDeleteStepDialogOpen] = useState(false);
|
||||
|
||||
@@ -800,26 +802,57 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{arePhoneNumbersActive.length > 0 ? (
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() => setIsTestAgentDialogOpen(true)}
|
||||
disabled={props.readOnly || !arePhoneNumbersActive.length}>
|
||||
<Icon name="phone" className="mr-2 h-4 w-4" />
|
||||
{t("test_agent")}
|
||||
</Button>
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
color="secondary"
|
||||
className="rounded-[10px]"
|
||||
disabled={props.readOnly}
|
||||
EndIcon="chevron-down">
|
||||
{t("test_agent")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
StartIcon="phone"
|
||||
onClick={() => setIsTestAgentDialogOpen(true)}>
|
||||
{t("phone_call")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
StartIcon="monitor"
|
||||
onClick={() => setIsWebCallDialogOpen(true)}>
|
||||
{t("web_call")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
) : (
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
setAgentConfigurationSheet((prev) => ({
|
||||
...prev,
|
||||
open: true,
|
||||
activeTab: "phoneNumber",
|
||||
}));
|
||||
}}
|
||||
disabled={props.readOnly}>
|
||||
{t("connect_phone_number")}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
setAgentConfigurationSheet((prev) => ({
|
||||
...prev,
|
||||
open: true,
|
||||
activeTab: "phoneNumber",
|
||||
}));
|
||||
}}
|
||||
disabled={props.readOnly}>
|
||||
{t("connect_phone_number")}
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() => setIsWebCallDialogOpen(true)}
|
||||
disabled={props.readOnly}
|
||||
StartIcon="monitor">
|
||||
{t("test_web_call")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -1447,6 +1480,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
}}
|
||||
readOnly={props.readOnly}
|
||||
teamId={teamId}
|
||||
isOrganization={props.isOrganization}
|
||||
workflowId={params?.workflow as string}
|
||||
workflowStepId={step?.id}
|
||||
form={form}
|
||||
@@ -1454,7 +1488,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
)}
|
||||
|
||||
{stepAgentId && (
|
||||
<TestAgentDialog
|
||||
<TestPhoneCallDialog
|
||||
open={isTestAgentDialogOpen}
|
||||
onOpenChange={setIsTestAgentDialogOpen}
|
||||
agentId={stepAgentId}
|
||||
@@ -1463,6 +1497,17 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{stepAgentId && (
|
||||
<WebCallDialog
|
||||
open={isWebCallDialogOpen}
|
||||
onOpenChange={setIsWebCallDialogOpen}
|
||||
agentId={stepAgentId}
|
||||
teamId={teamId}
|
||||
isOrganization={props.isOrganization}
|
||||
form={form}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Unsubscribe Confirmation Dialog */}
|
||||
<Dialog open={isUnsubscribeDialogOpen} onOpenChange={setIsUnsubscribeDialogOpen}>
|
||||
<DialogContent type="creation" title={t("unsubscribe_phone_number")}>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import authedProcedure from "../../../procedures/authedProcedure";
|
||||
import { router } from "../../../trpc";
|
||||
import { eventOwnerProcedure } from "../eventTypes/util";
|
||||
import { ZCreateInputSchema } from "./create.schema";
|
||||
import { ZCreateWebCallInputSchema } from "./createWebCall.schema";
|
||||
import { ZDeleteInputSchema } from "./delete.schema";
|
||||
import { ZGetInputSchema } from "./get.schema";
|
||||
import { ZListInputSchema } from "./list.schema";
|
||||
@@ -61,4 +63,13 @@ export const aiVoiceAgentRouter = router({
|
||||
input,
|
||||
});
|
||||
}),
|
||||
|
||||
createWebCall: eventOwnerProcedure.input(ZCreateWebCallInputSchema).mutation(async ({ ctx, input }) => {
|
||||
const { createWebCallHandler } = await import("./createWebCall.handler");
|
||||
|
||||
return createWebCallHandler({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone";
|
||||
import logger from "@calcom/lib/logger";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../types";
|
||||
import type { TCreateWebCallInputSchema } from "./createWebCall.schema";
|
||||
|
||||
type CreateWebCallHandlerOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TCreateWebCallInputSchema;
|
||||
};
|
||||
|
||||
export const createWebCallHandler = async ({ ctx, input }: CreateWebCallHandlerOptions) => {
|
||||
const timeZone = ctx.user.timeZone ?? "Europe/London";
|
||||
const eventTypeId = input.eventTypeId;
|
||||
|
||||
const aiService = createDefaultAIPhoneServiceProvider();
|
||||
|
||||
const agent = await aiService.getAgentWithDetails({
|
||||
id: input.agentId,
|
||||
userId: ctx.user.id,
|
||||
teamId: input.teamId,
|
||||
});
|
||||
|
||||
if (!agent?.providerAgentId) {
|
||||
logger.error(`Agent not found or access denied for agentId ${input.agentId}, userId ${ctx.user.id}`);
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Agent not found or you don't have permission to access this agent",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await aiService.updateToolsFromAgentId(agent.providerAgentId, {
|
||||
eventTypeId,
|
||||
timeZone,
|
||||
userId: ctx.user.id,
|
||||
teamId: input.teamId,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update tools for agent ${agent.providerAgentId}: ${error}`);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to configure agent for web call",
|
||||
});
|
||||
}
|
||||
|
||||
return await aiService.createWebCall({
|
||||
agentId: input.agentId,
|
||||
userId: ctx.user.id,
|
||||
teamId: input.teamId,
|
||||
timeZone,
|
||||
eventTypeId,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZCreateWebCallInputSchema = z.object({
|
||||
agentId: z.string().min(1, "Agent ID is required"),
|
||||
teamId: z.number().optional(),
|
||||
eventTypeId: z.number().min(1, "Event Type ID is required"),
|
||||
});
|
||||
|
||||
export type TCreateWebCallInputSchema = z.infer<typeof ZCreateWebCallInputSchema>;
|
||||
@@ -4,11 +4,13 @@ import { router } from "../../../trpc";
|
||||
import { ZBuyCreditsSchema } from "./buyCredits.schema";
|
||||
import { ZDownloadExpenseLogSchema } from "./downloadExpenseLog.schema";
|
||||
import { ZGetAllCreditsSchema } from "./getAllCredits.schema";
|
||||
import { ZHasAvailableCreditsSchema } from "./hasAvailableCredits.schema";
|
||||
|
||||
type CreditsCache = {
|
||||
getAllCredits?: typeof import("./getAllCredits.handler").getAllCreditsHandler;
|
||||
buyCredits?: typeof import("./buyCredits.handler").buyCreditsHandler;
|
||||
downloadExpenseLog?: typeof import("./downloadExpenseLog.handler").downloadExpenseLogHandler;
|
||||
hasAvailableCredits?: typeof import("./hasAvailableCredits.handler").hasAvailableCreditsHandler;
|
||||
};
|
||||
|
||||
const UNSTABLE_HANDLER_CACHE: CreditsCache = {};
|
||||
@@ -31,6 +33,23 @@ export const creditsRouter = router({
|
||||
input,
|
||||
});
|
||||
}),
|
||||
hasAvailableCredits: authedProcedure.input(ZHasAvailableCreditsSchema).query(async ({ input, ctx }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.hasAvailableCredits) {
|
||||
UNSTABLE_HANDLER_CACHE.hasAvailableCredits = await import("./hasAvailableCredits.handler").then(
|
||||
(mod) => mod.hasAvailableCreditsHandler
|
||||
);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.hasAvailableCredits) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.hasAvailableCredits({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
buyCredits: authedProcedure.input(ZBuyCreditsSchema).mutation(async ({ input, ctx }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.buyCredits) {
|
||||
UNSTABLE_HANDLER_CACHE.buyCredits = await import("./buyCredits.handler").then(
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||
|
||||
import type { THasAvailableCreditsSchema } from "./hasAvailableCredits.schema";
|
||||
|
||||
type HasAvailableCreditsOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: THasAvailableCreditsSchema;
|
||||
};
|
||||
|
||||
export const hasAvailableCreditsHandler = async ({ ctx, input }: HasAvailableCreditsOptions) => {
|
||||
const { teamId } = input;
|
||||
|
||||
const orgId = ctx.user.organization?.id;
|
||||
|
||||
const { CreditService } = await import("@calcom/features/ee/billing/credit-service");
|
||||
const creditService = new CreditService();
|
||||
|
||||
const hasCredits = await creditService.hasAvailableCredits({
|
||||
userId: ctx.user.id,
|
||||
teamId: teamId ?? orgId,
|
||||
});
|
||||
return hasCredits;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZHasAvailableCreditsSchema = z.object({
|
||||
teamId: z.number().optional(),
|
||||
});
|
||||
|
||||
export type THasAvailableCreditsSchema = z.infer<typeof ZHasAvailableCreditsSchema>;
|
||||
@@ -148,4 +148,8 @@ export const lucideIconList = new Set([
|
||||
"bookmark",
|
||||
"square-pen",
|
||||
"list-filter",
|
||||
"phone-off",
|
||||
"mic-off",
|
||||
"mic",
|
||||
"monitor",
|
||||
]);
|
||||
|
||||
@@ -98,11 +98,15 @@ export type IconName =
|
||||
| "menu"
|
||||
| "message-circle"
|
||||
| "messages-square"
|
||||
| "mic-off"
|
||||
| "mic"
|
||||
| "monitor"
|
||||
| "moon"
|
||||
| "paintbrush"
|
||||
| "paperclip"
|
||||
| "pencil"
|
||||
| "phone-call"
|
||||
| "phone-off"
|
||||
| "phone"
|
||||
| "plus"
|
||||
| "refresh-ccw"
|
||||
|
||||
@@ -2640,6 +2640,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@bufbuild/protobuf@npm:^1.10.0":
|
||||
version: 1.10.1
|
||||
resolution: "@bufbuild/protobuf@npm:1.10.1"
|
||||
checksum: 403838ad278d504e33e72ec0f64ce1bac9f5025ee6396253382e821a1fe0c371a58ffe45a0e8f23306205b6890c5f83e85828168a35dae118915cd4c3d091177
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@bundled-es-modules/cookie@npm:^2.0.1":
|
||||
version: 2.0.1
|
||||
resolution: "@bundled-es-modules/cookie@npm:2.0.1"
|
||||
@@ -4358,6 +4365,7 @@ __metadata:
|
||||
react-use-intercom: 1.5.1
|
||||
recoil: ^0.7.7
|
||||
remove-markdown: ^0.5.0
|
||||
retell-client-js-sdk: ^2.0.0
|
||||
retell-sdk: ^4.40.0
|
||||
rrule: ^2.7.1
|
||||
sanitize-html: ^2.10.0
|
||||
@@ -8988,6 +8996,22 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@livekit/mutex@npm:1.1.1":
|
||||
version: 1.1.1
|
||||
resolution: "@livekit/mutex@npm:1.1.1"
|
||||
checksum: 44a31eb7a913357ffb57d04eaa20f7507c0a659638c6dfaba9a413c21a3397aa351497f7c77bca3c06a29ac2cbe83698a5f96b9230012a24b86ac8366e9b8666
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@livekit/protocol@npm:1.39.3":
|
||||
version: 1.39.3
|
||||
resolution: "@livekit/protocol@npm:1.39.3"
|
||||
dependencies:
|
||||
"@bufbuild/protobuf": ^1.10.0
|
||||
checksum: 72aa3b9e82203ccec039047518d841d65b10dcc11ab9d661f269eab8a8672aed7b2d072f4f09cc6fb94a2579542b0a9216d7f66476d34cd1675c13e1ebcc119f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ljharb/through@npm:^2.3.11":
|
||||
version: 2.3.13
|
||||
resolution: "@ljharb/through@npm:2.3.13"
|
||||
@@ -33757,6 +33781,25 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"livekit-client@npm:^2.5.1":
|
||||
version: 2.15.6
|
||||
resolution: "livekit-client@npm:2.15.6"
|
||||
dependencies:
|
||||
"@livekit/mutex": 1.1.1
|
||||
"@livekit/protocol": 1.39.3
|
||||
events: ^3.3.0
|
||||
loglevel: ^1.9.2
|
||||
sdp-transform: ^2.15.0
|
||||
ts-debounce: ^4.0.0
|
||||
tslib: 2.8.1
|
||||
typed-emitter: ^2.1.0
|
||||
webrtc-adapter: ^9.0.1
|
||||
peerDependencies:
|
||||
"@types/dom-mediacapture-record": ^1
|
||||
checksum: 3dda194dbb5c9ff83038fafa8ead353e9abea84bdd7fdb4c65e164bcfab2d48ccec04caf6df2bace164c4475c4a6ae753c6154654abca984fe96384d885721af
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"load-bmfont@npm:^1.3.1, load-bmfont@npm:^1.4.0":
|
||||
version: 1.4.1
|
||||
resolution: "load-bmfont@npm:1.4.1"
|
||||
@@ -34102,6 +34145,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"loglevel@npm:^1.9.2":
|
||||
version: 1.9.2
|
||||
resolution: "loglevel@npm:1.9.2"
|
||||
checksum: 896c67b90a507bfcfc1e9a4daa7bf789a441dd70d95cd13b998d6dd46233a3bfadfb8fadb07250432bbfb53bf61e95f2520f9b11f9d3175cc460e5c251eca0af
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"long-timeout@npm:0.1.1":
|
||||
version: 0.1.1
|
||||
resolution: "long-timeout@npm:0.1.1"
|
||||
@@ -42186,6 +42236,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"retell-client-js-sdk@npm:^2.0.0":
|
||||
version: 2.0.7
|
||||
resolution: "retell-client-js-sdk@npm:2.0.7"
|
||||
dependencies:
|
||||
eventemitter3: ^5.0.1
|
||||
livekit-client: ^2.5.1
|
||||
checksum: 8c0f0a0ed95b79a7c0e4b9dad1f1b0369c8e05bc6e0219945ad271426e5348d644d113b94d2c9a35eff62919748a3f638c35e76e3ddb413fa5062295b4de17e4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"retell-sdk@npm:^4.40.0":
|
||||
version: 4.41.0
|
||||
resolution: "retell-sdk@npm:4.41.0"
|
||||
@@ -42667,6 +42727,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"rxjs@npm:*, rxjs@npm:^7.8.2":
|
||||
version: 7.8.2
|
||||
resolution: "rxjs@npm:7.8.2"
|
||||
dependencies:
|
||||
tslib: ^2.1.0
|
||||
checksum: 2f233d7c832a6c255dabe0759014d7d9b1c9f1cb2f2f0d59690fd11c883c9826ea35a51740c06ab45b6ade0d9087bde9192f165cba20b6730d344b831ef80744
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"rxjs@npm:7.8.1, rxjs@npm:^7.8.1":
|
||||
version: 7.8.1
|
||||
resolution: "rxjs@npm:7.8.1"
|
||||
@@ -42685,15 +42754,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"rxjs@npm:^7.8.2":
|
||||
version: 7.8.2
|
||||
resolution: "rxjs@npm:7.8.2"
|
||||
dependencies:
|
||||
tslib: ^2.1.0
|
||||
checksum: 2f233d7c832a6c255dabe0759014d7d9b1c9f1cb2f2f0d59690fd11c883c9826ea35a51740c06ab45b6ade0d9087bde9192f165cba20b6730d344b831ef80744
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"safe-array-concat@npm:^1.0.1":
|
||||
version: 1.1.0
|
||||
resolution: "safe-array-concat@npm:1.1.0"
|
||||
@@ -42939,6 +42999,22 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"sdp-transform@npm:^2.15.0":
|
||||
version: 2.15.0
|
||||
resolution: "sdp-transform@npm:2.15.0"
|
||||
bin:
|
||||
sdp-verify: checker.js
|
||||
checksum: 63a21fdb3f97adc31019c7fb896e77076bdd2405d9e0eb6bd991b6f51973552bdd99409878aae8f8375f516af162d5a4176e102e090e24c870accafb9f73886f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"sdp@npm:^3.2.0":
|
||||
version: 3.2.1
|
||||
resolution: "sdp@npm:3.2.1"
|
||||
checksum: 88c24ceb46bf29f7ad3ece249db6353668a7c970b5831cbf1f4f371d8960ae90a4b0e3b9b05aa499a5038fc145662578456530c7b3efcaf623fdb0ef8ab243a0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"section-matter@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "section-matter@npm:1.0.0"
|
||||
@@ -45943,6 +46019,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ts-debounce@npm:^4.0.0":
|
||||
version: 4.0.0
|
||||
resolution: "ts-debounce@npm:4.0.0"
|
||||
checksum: e1e509632c5aa09c40d3fa315b3a95b2c2e8813ccc706a400aa08e41f691e658061f34b42a1e8a578a043540d6db198e6ecf3ce26a5356a02a0940985fb1e379
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ts-dedent@npm:^2.0.0":
|
||||
version: 2.2.0
|
||||
resolution: "ts-dedent@npm:2.2.0"
|
||||
@@ -46250,7 +46333,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tslib@npm:2, tslib@npm:^2.0.3, tslib@npm:^2.6.2, tslib@npm:^2.6.3, tslib@npm:^2.8.0, tslib@npm:^2.8.1":
|
||||
"tslib@npm:2, tslib@npm:2.8.1, tslib@npm:^2.0.3, tslib@npm:^2.6.2, tslib@npm:^2.6.3, tslib@npm:^2.8.0, tslib@npm:^2.8.1":
|
||||
version: 2.8.1
|
||||
resolution: "tslib@npm:2.8.1"
|
||||
checksum: e4aba30e632b8c8902b47587fd13345e2827fa639e7c3121074d5ee0880723282411a8838f830b55100cbe4517672f84a2472667d355b81e8af165a55dc6203a
|
||||
@@ -46740,6 +46823,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"typed-emitter@npm:^2.1.0":
|
||||
version: 2.1.0
|
||||
resolution: "typed-emitter@npm:2.1.0"
|
||||
dependencies:
|
||||
rxjs: "*"
|
||||
dependenciesMeta:
|
||||
rxjs:
|
||||
optional: true
|
||||
checksum: 95821a9e05784b972cc9d152891fd12a56cb4b1a7c57e768c02bea6a8984da7aff8f19404a7b69eea11fae2a3b6c0c510a4c510f575f50162c759ae9059f2520
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"typedarray-to-buffer@npm:~1.0.0":
|
||||
version: 1.0.4
|
||||
resolution: "typedarray-to-buffer@npm:1.0.4"
|
||||
@@ -48305,6 +48400,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"webrtc-adapter@npm:^9.0.1":
|
||||
version: 9.0.3
|
||||
resolution: "webrtc-adapter@npm:9.0.3"
|
||||
dependencies:
|
||||
sdp: ^3.2.0
|
||||
checksum: 2b29e6e6ed1018f62b2077fffd4f6d7fa9993f98091b7af459261092c2c05125bfa146b4d0dd76ed9e667703a1391743e0bb76a3754239c7d20acb034026eae3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"websocket-driver@npm:>=0.5.1":
|
||||
version: 0.7.4
|
||||
resolution: "websocket-driver@npm:0.7.4"
|
||||
|
||||
Reference in New Issue
Block a user