feat: support web calls in webhook (#23788)
* feat: support web calls in webhook * fix: test * chore: formating * fix: type error * chore: improvements * chore: improvements
This commit is contained in:
1 parent
5230694e2c
commit
3915348d03
2 files changed
+303
-50
No files matched your search
@@ -2,8 +2,9 @@ import type { NextRequest } from "next/server";
|
||||
import { Retell } from "retell-sdk";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { PrismaAgentRepository } from "@calcom/lib/server/repository/PrismaAgentRepository";
|
||||
import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository";
|
||||
import type { CalAiPhoneNumber, User, Team } from "@calcom/prisma/client";
|
||||
import type { CalAiPhoneNumber, User, Team, Agent } from "@calcom/prisma/client";
|
||||
|
||||
import { POST } from "../route";
|
||||
|
||||
@@ -25,6 +26,7 @@ type RetellWebhookBody = {
|
||||
from_number?: string;
|
||||
to_number?: string;
|
||||
direction?: "inbound" | "outbound";
|
||||
call_type?: string;
|
||||
call_status?: string;
|
||||
start_timestamp?: number;
|
||||
end_timestamp?: number;
|
||||
@@ -85,6 +87,12 @@ vi.mock("@calcom/lib/server/repository/PrismaPhoneNumberRepository", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/server/repository/PrismaAgentRepository", () => ({
|
||||
PrismaAgentRepository: {
|
||||
findByProviderAgentId: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextResponse: {
|
||||
json: vi.fn((data, options) => ({
|
||||
@@ -683,4 +691,179 @@ describe("Retell AI Webhook Handler", () => {
|
||||
expect(data.message).toContain("Error charging credits for Retell AI call");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Web Call Tests", () => {
|
||||
const mockAgent: Pick<
|
||||
Agent,
|
||||
"id" | "name" | "providerAgentId" | "enabled" | "userId" | "teamId" | "createdAt" | "updatedAt"
|
||||
> = {
|
||||
id: "agent-123",
|
||||
name: "Test Agent",
|
||||
providerAgentId: "agent_5e3e0d29d692172c2c24d8f9a7",
|
||||
enabled: true,
|
||||
userId: 1,
|
||||
teamId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(Retell.verify).mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("should process web call with valid agent and charge credits", async () => {
|
||||
vi.mocked(PrismaAgentRepository.findByProviderAgentId).mockResolvedValue(mockAgent);
|
||||
mockChargeCredits.mockResolvedValue(undefined);
|
||||
|
||||
const body: RetellWebhookBody = {
|
||||
event: "call_analyzed",
|
||||
call: {
|
||||
call_id: "call_bcd94f5a50832873a5fd68cb1aa",
|
||||
call_type: "web_call",
|
||||
agent_id: "agent_5e3e0d29d692172c2c24d8f9a7",
|
||||
call_status: "ended",
|
||||
start_timestamp: 1757673314024,
|
||||
end_timestamp: 1757673321010,
|
||||
call_cost: {
|
||||
total_duration_seconds: 7,
|
||||
combined_cost: 1.3416667,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const request = createMockRequest(body, "valid-signature");
|
||||
const response = await callPOST(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(data.success).toBe(true);
|
||||
|
||||
expect(PrismaAgentRepository.findByProviderAgentId).toHaveBeenCalledWith({
|
||||
providerAgentId: "agent_5e3e0d29d692172c2c24d8f9a7",
|
||||
});
|
||||
|
||||
expect(mockChargeCredits).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 1,
|
||||
teamId: undefined,
|
||||
credits: 4, // 7 seconds = 0.117 minutes * $0.29 = $0.034 = 4 credits (rounded up)
|
||||
callDuration: 7,
|
||||
externalRef: "retell:call_bcd94f5a50832873a5fd68cb1aa",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle web call with team agent", async () => {
|
||||
const teamAgent = { ...mockAgent, userId: 2, teamId: 10 };
|
||||
vi.mocked(PrismaAgentRepository.findByProviderAgentId).mockResolvedValue(teamAgent);
|
||||
mockChargeCredits.mockResolvedValue(undefined);
|
||||
|
||||
const body: RetellWebhookBody = {
|
||||
event: "call_analyzed",
|
||||
call: {
|
||||
call_id: "web-call-team",
|
||||
call_type: "web_call",
|
||||
agent_id: "agent_team_123",
|
||||
call_status: "ended",
|
||||
start_timestamp: 1757673314024,
|
||||
call_cost: {
|
||||
total_duration_seconds: 60,
|
||||
combined_cost: 2.0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const request = createMockRequest(body, "valid-signature");
|
||||
const response = await callPOST(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockChargeCredits).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 2,
|
||||
teamId: 10,
|
||||
credits: 29, // 60 seconds = 1 minute * $0.29 = 29 credits
|
||||
callDuration: 60,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle web call without from_number", async () => {
|
||||
vi.mocked(PrismaAgentRepository.findByProviderAgentId).mockResolvedValue(mockAgent);
|
||||
mockChargeCredits.mockResolvedValue(undefined);
|
||||
|
||||
const body: RetellWebhookBody = {
|
||||
event: "call_analyzed",
|
||||
call: {
|
||||
call_id: "web-call-no-phone",
|
||||
agent_id: "agent_5e3e0d29d692172c2c24d8f9a7",
|
||||
call_status: "ended",
|
||||
start_timestamp: 1757673314024,
|
||||
call_cost: {
|
||||
total_duration_seconds: 30,
|
||||
combined_cost: 1.0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const request = createMockRequest(body, "valid-signature");
|
||||
const response = await callPOST(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(PrismaAgentRepository.findByProviderAgentId).toHaveBeenCalled();
|
||||
expect(PrismaPhoneNumberRepository.findByPhoneNumber).not.toHaveBeenCalled();
|
||||
expect(mockChargeCredits).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle web call with missing agent_id", async () => {
|
||||
const body: RetellWebhookBody = {
|
||||
event: "call_analyzed",
|
||||
call: {
|
||||
call_id: "web-call-no-agent",
|
||||
call_type: "web_call",
|
||||
call_status: "ended",
|
||||
call_cost: {
|
||||
total_duration_seconds: 30,
|
||||
combined_cost: 1.0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const request = createMockRequest(body, "valid-signature");
|
||||
const response = await callPOST(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(PrismaAgentRepository.findByProviderAgentId).not.toHaveBeenCalled();
|
||||
expect(mockChargeCredits).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle web call with agent not found", async () => {
|
||||
vi.mocked(PrismaAgentRepository.findByProviderAgentId).mockResolvedValue(null);
|
||||
|
||||
const body: RetellWebhookBody = {
|
||||
event: "call_analyzed",
|
||||
call: {
|
||||
call_id: "web-call-no-agent-found",
|
||||
call_type: "web_call",
|
||||
agent_id: "non-existent-agent",
|
||||
call_status: "ended",
|
||||
start_timestamp: 1757673314024,
|
||||
call_cost: {
|
||||
total_duration_seconds: 30,
|
||||
combined_cost: 1.0,
|
||||
},
|
||||
// Web calls don't have from_number, to_number, direction
|
||||
},
|
||||
};
|
||||
|
||||
const request = createMockRequest(body, "valid-signature");
|
||||
const response = await callPOST(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(PrismaAgentRepository.findByProviderAgentId).toHaveBeenCalledWith({
|
||||
providerAgentId: "non-existent-agent",
|
||||
});
|
||||
expect(mockChargeCredits).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { z } from "zod";
|
||||
import { CreditService } from "@calcom/features/ee/billing/credit-service";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { PrismaAgentRepository } from "@calcom/lib/server/repository/PrismaAgentRepository";
|
||||
import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository";
|
||||
import { CreditUsageType } from "@calcom/prisma/enums";
|
||||
|
||||
@@ -18,9 +19,11 @@ const RetellWebhookSchema = z.object({
|
||||
.object({
|
||||
call_id: z.string(),
|
||||
agent_id: z.string().optional(),
|
||||
from_number: z.string(),
|
||||
to_number: z.string(),
|
||||
direction: z.enum(["inbound", "outbound"]),
|
||||
// Make phone fields optional for web calls
|
||||
from_number: z.string().optional(),
|
||||
to_number: z.string().optional(),
|
||||
direction: z.enum(["inbound", "outbound"]).optional(),
|
||||
call_type: z.string().optional(),
|
||||
call_status: z.string(),
|
||||
start_timestamp: z.number(),
|
||||
end_timestamp: z.number().optional(),
|
||||
@@ -59,44 +62,27 @@ const RetellWebhookSchema = z.object({
|
||||
.passthrough(),
|
||||
});
|
||||
|
||||
async function handleCallAnalyzed(callData: any) {
|
||||
const { from_number, call_id, call_cost } = callData;
|
||||
if (
|
||||
!call_cost ||
|
||||
typeof call_cost.total_duration_seconds !== "number" ||
|
||||
!Number.isFinite(call_cost.total_duration_seconds) ||
|
||||
call_cost.total_duration_seconds <= 0
|
||||
) {
|
||||
log.error(
|
||||
`Invalid or missing call_cost.total_duration_seconds for call ${call_id}: ${safeStringify(call_cost)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const phoneNumber = await PrismaPhoneNumberRepository.findByPhoneNumber({ phoneNumber: from_number });
|
||||
|
||||
if (!phoneNumber) {
|
||||
log.error(`No phone number found for ${from_number}, cannot deduct credits`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Support both personal and team phone numbers
|
||||
const userId = phoneNumber.userId;
|
||||
const teamId = phoneNumber.teamId;
|
||||
|
||||
if (!userId && !teamId) {
|
||||
log.error(`Phone number ${from_number} has no associated user or team`);
|
||||
return;
|
||||
}
|
||||
|
||||
async function chargeCreditsForCall({
|
||||
userId,
|
||||
teamId,
|
||||
callCost,
|
||||
callId,
|
||||
callDuration,
|
||||
}: {
|
||||
userId?: number;
|
||||
teamId?: number;
|
||||
callCost: number;
|
||||
callId: string;
|
||||
callDuration: number;
|
||||
}) {
|
||||
const rawRatePerMinute = process.env.CAL_AI_CALL_RATE_PER_MINUTE ?? "0.29";
|
||||
const ratePerMinute = Number.parseFloat(rawRatePerMinute);
|
||||
const safeRatePerMinute = Number.isFinite(ratePerMinute) && ratePerMinute > 0 ? ratePerMinute : 0.29;
|
||||
|
||||
const durationInMinutes = call_cost.total_duration_seconds / 60;
|
||||
const callCost = durationInMinutes * safeRatePerMinute;
|
||||
const durationInMinutes = callDuration / 60;
|
||||
const calculatedCallCost = durationInMinutes * safeRatePerMinute;
|
||||
// Convert to cents and round up to ensure we don't undercharge
|
||||
const creditsToDeduct = Math.ceil(callCost * 100);
|
||||
const creditsToDeduct = Math.ceil(calculatedCallCost * 100);
|
||||
|
||||
const creditService = new CreditService();
|
||||
|
||||
@@ -105,15 +91,22 @@ async function handleCallAnalyzed(callData: any) {
|
||||
userId: userId ?? undefined,
|
||||
teamId: teamId ?? undefined,
|
||||
credits: creditsToDeduct,
|
||||
callDuration: call_cost.total_duration_seconds,
|
||||
callDuration: callDuration,
|
||||
creditFor: CreditUsageType.CAL_AI_PHONE_CALL,
|
||||
externalRef: `retell:${call_id}`,
|
||||
externalRef: `retell:${callId}`,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully charged ${creditsToDeduct} credits (${callDuration}s at $${safeRatePerMinute}/min) for ${
|
||||
teamId ? `team:${teamId}` : ""
|
||||
} ${userId ? `user:${userId}` : ""}, call ${callId}`,
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Error charging credits for Retell AI call", {
|
||||
error: e,
|
||||
call_id,
|
||||
call_cost,
|
||||
call_id: callId,
|
||||
call_cost: callCost,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
@@ -124,15 +117,88 @@ async function handleCallAnalyzed(callData: any) {
|
||||
}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully charged ${creditsToDeduct} credits (${
|
||||
call_cost.total_duration_seconds
|
||||
}s at $${safeRatePerMinute}/min) for ${teamId ? `team:${teamId}` : ""} ${
|
||||
userId ? `user:${userId}` : ""
|
||||
}, call ${call_id}`,
|
||||
};
|
||||
async function handleCallAnalyzed(callData: any) {
|
||||
const { from_number, call_id, call_cost, call_type, agent_id } = callData;
|
||||
|
||||
if (
|
||||
!call_cost ||
|
||||
typeof call_cost.total_duration_seconds !== "number" ||
|
||||
!Number.isFinite(call_cost.total_duration_seconds) ||
|
||||
call_cost.total_duration_seconds <= 0
|
||||
) {
|
||||
log.info(
|
||||
`Invalid or missing call_cost.total_duration_seconds for call ${call_id}: ${safeStringify(call_cost)}`
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
message: `Invalid or missing call_cost.total_duration_seconds for call ${call_id}`
|
||||
};
|
||||
}
|
||||
|
||||
let userId: number | undefined;
|
||||
let teamId: number | undefined;
|
||||
|
||||
// Handle web calls vs phone calls
|
||||
if (call_type === "web_call" || !from_number) {
|
||||
if (!agent_id) {
|
||||
log.error(`Web call ${call_id} missing agent_id, cannot charge credits`);
|
||||
return {
|
||||
success: false,
|
||||
message: `Web call ${call_id} missing agent_id, cannot charge credits`
|
||||
};
|
||||
}
|
||||
|
||||
const agent = await PrismaAgentRepository.findByProviderAgentId({
|
||||
providerAgentId: agent_id,
|
||||
});
|
||||
|
||||
if (!agent) {
|
||||
log.error(`No agent found for providerAgentId ${agent_id}, call ${call_id}`);
|
||||
return {
|
||||
success: false,
|
||||
message: `No agent found for providerAgentId ${agent_id}, call ${call_id}`
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
userId = agent.userId ?? undefined;
|
||||
teamId = agent.teamId ?? undefined;
|
||||
|
||||
log.info(`Processing web call ${call_id} for agent ${agent_id}, user ${userId}, team ${teamId}`);
|
||||
} else {
|
||||
const phoneNumber = await PrismaPhoneNumberRepository.findByPhoneNumber({
|
||||
phoneNumber: from_number,
|
||||
});
|
||||
|
||||
if (!phoneNumber) {
|
||||
const msg = `No phone number found for ${from_number}, call ${call_id}`;
|
||||
log.error(msg);
|
||||
return { success: false, message: msg };
|
||||
}
|
||||
|
||||
userId = phoneNumber.userId ?? undefined;
|
||||
teamId = phoneNumber.teamId ?? undefined;
|
||||
|
||||
log.info(`Processing phone call ${call_id} from ${from_number}, user ${userId}, team ${teamId}`);
|
||||
}
|
||||
|
||||
if (!userId && !teamId) {
|
||||
log.error(`Call ${call_id} has no associated user or team`);
|
||||
return {
|
||||
success: false,
|
||||
message: `Call ${call_id} has no associated user or team`,
|
||||
};
|
||||
}
|
||||
|
||||
return await chargeCreditsForCall({
|
||||
userId,
|
||||
teamId,
|
||||
callCost: call_cost.combined_cost || 0,
|
||||
callId: call_id,
|
||||
callDuration: call_cost.total_duration_seconds,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -192,6 +258,8 @@ async function handler(request: NextRequest) {
|
||||
try {
|
||||
const payload = RetellWebhookSchema.parse(body);
|
||||
const callData = payload.call;
|
||||
|
||||
// Skip inbound calls (only for phone calls, web calls don't have direction)
|
||||
if (callData.direction === "inbound") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -202,7 +270,9 @@ async function handler(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
log.info(`Received Retell AI webhook: ${payload.event} for call ${callData.call_id}`);
|
||||
log.info(`Received Retell AI webhook: ${payload.event} for call ${callData.call_id}`, {
|
||||
call_id: callData.call_id,
|
||||
});
|
||||
|
||||
const result = await handleCallAnalyzed(callData);
|
||||
|
||||
|
||||
Reference in new issue
Block a user