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:
Udit Takkar
2025-09-10 12:41:10 -03:00
committed by GitHub
co-authored by Peer Richelsen
parent 2e96600529
commit a8a3e48465
22 changed files with 1043 additions and 44 deletions
+1
View File
@@ -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",
+22
View File
@@ -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}
@@ -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"
+114 -10
View File
@@ -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"