* feat: Cal.ai Self Serve #2 * chore: fix import and remove logs * fix: update checkout session * fix: type errors and test * fix: imports * fix: type err * fix: type error * fix: tests * chore: save progress * fix: workflow flow * fix: workflow update bug * tests: add unit tests for retell ai webhoo * fix: status code * fix: test and delete bug * fix: add dynamic variables * fix: type err * chore: update unit test * fix: type error * chore: update default prompt * fix: type errors * fix: workflow permissions * fix: workflow page * fix: translations * feat: add call duration * chore: add booking uid * fix: button positioning * chore: update tests * chore: improvements * chore: some more improvements * refactor: improvements * refactor: code feedback * refactor: improvements * feat: enable credits for orgs (#23077) * Show credits UI for orgs * fix stripe callback url when buying credits * give orgs 20% credits * add test for calulating credits --------- Co-authored-by: CarinaWolli <wollencarina@gmail.com> Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> * fix: types * fix: types * chore: error * fix: type error * fix: type error * chore: mock env * feat: add idempotency key to prevent double charging * chore: add userId and teamId * fix: skip inbound calls * chore: update tests * feat: add feature flag for voice agent * feat: finish test call and other improvements * chore: add alert * chore: update .env.example * chore: improvements * fix: update tests * refactor: remove un necessary * feat: add setup badge * chore: improvements * fix: use referene id * chore: improvements * fix: type error * fix: type * refactor: change pricing logic * refactor: update tests * fix: conflicts * fix: billing link for orgs * fix: types * refactor: move feature flag up * fix: alert and test call credit check * fix: update unit tests * fix: feedback * refactor: improvements * refactor: move handlers to separate files * fix: types * fix: missing import * fix: type * refactor: change general tools functions handling * refactor: use repository * refactor: improvements * fix: types * fix: type errorr * fix: auth check * feat: add creditFor * fix: update defualt prompt * fix: throw error on frontend * fix: update unit tests * fix: use deleteAllWorkflowReminders * refactor: add connect phone number * refactor: improvements * chore: translation * chore: update message * chore: translation * design improvements buy number dialog * add translation for error message * use translation key in error message * refactor: improve connect phone number tab * feat: support un saved workflow to tests * chore: remove un used * fix: remove un used * fix: remove un used * refactor: similify billing --------- Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Co-authored-by: CarinaWolli <wollencarina@gmail.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com> Co-authored-by: Keith Williams <keithwillcode@gmail.com>
230 lines
7.2 KiB
TypeScript
230 lines
7.2 KiB
TypeScript
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
|
import type { NextRequest } from "next/server";
|
|
import { NextResponse } from "next/server";
|
|
import { Retell } from "retell-sdk";
|
|
import { z } from "zod";
|
|
|
|
import { CreditService } from "@calcom/features/ee/billing/credit-service";
|
|
import logger from "@calcom/lib/logger";
|
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
|
import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository";
|
|
import { CreditUsageType } from "@calcom/prisma/enums";
|
|
|
|
const log = logger.getSubLogger({ prefix: ["retell-ai-webhook"] });
|
|
|
|
const RetellWebhookSchema = z.object({
|
|
event: z.enum(["call_started", "call_ended", "call_analyzed"]),
|
|
call: z
|
|
.object({
|
|
call_id: z.string(),
|
|
agent_id: z.string().optional(),
|
|
from_number: z.string(),
|
|
to_number: z.string(),
|
|
direction: z.enum(["inbound", "outbound"]),
|
|
call_status: z.string(),
|
|
start_timestamp: z.number(),
|
|
end_timestamp: z.number().optional(),
|
|
disconnection_reason: z.string().optional(),
|
|
metadata: z.record(z.any()).optional(),
|
|
retell_llm_dynamic_variables: z.record(z.any()).optional(),
|
|
transcript: z.string().optional(),
|
|
opt_out_sensitive_data_storage: z.boolean().optional(),
|
|
call_cost: z
|
|
.object({
|
|
product_costs: z
|
|
.array(
|
|
z.object({
|
|
product: z.string(),
|
|
unitPrice: z.number().optional(),
|
|
cost: z.number().optional(),
|
|
})
|
|
)
|
|
.optional(),
|
|
total_duration_seconds: z.number().optional(),
|
|
total_duration_unit_price: z.number().optional(),
|
|
total_one_time_price: z.number().optional(),
|
|
combined_cost: z.number().optional(),
|
|
})
|
|
.optional(),
|
|
call_analysis: z
|
|
.object({
|
|
call_summary: z.string().optional(),
|
|
in_voicemail: z.boolean().optional(),
|
|
user_sentiment: z.string().optional(),
|
|
call_successful: z.boolean().optional(),
|
|
custom_analysis_data: z.record(z.any()).optional(),
|
|
})
|
|
.optional(),
|
|
})
|
|
.passthrough(),
|
|
});
|
|
|
|
async function handleCallAnalyzed(callData: any) {
|
|
const { from_number, call_id, call_cost } = callData;
|
|
if (
|
|
!call_cost ||
|
|
typeof call_cost.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;
|
|
}
|
|
|
|
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;
|
|
// Convert to cents and round up to ensure we don't undercharge
|
|
const creditsToDeduct = Math.ceil(callCost * 100);
|
|
|
|
const creditService = new CreditService();
|
|
|
|
try {
|
|
await creditService.chargeCredits({
|
|
userId: userId ?? undefined,
|
|
teamId: teamId ?? undefined,
|
|
credits: creditsToDeduct,
|
|
callDuration: call_cost.total_duration_seconds,
|
|
creditFor: CreditUsageType.CAL_AI_PHONE_CALL,
|
|
externalRef: `retell:${call_id}`,
|
|
});
|
|
} catch (e) {
|
|
log.error("Error charging credits for Retell AI call", {
|
|
error: e,
|
|
call_id,
|
|
call_cost,
|
|
userId,
|
|
teamId,
|
|
});
|
|
return {
|
|
success: false,
|
|
message: `Error charging credits for Retell AI call: ${
|
|
e instanceof Error ? e.message : "Unknown error"
|
|
}`,
|
|
};
|
|
}
|
|
|
|
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}`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Retell AI Webhook Handler
|
|
*
|
|
* Setup Instructions:
|
|
* 1. Add this webhook URL to your Retell AI dashboard: https://yourdomain.com/api/webhooks/retell-ai
|
|
* 2. Ensure your domain is accessible from the internet (for local development, use ngrok or similar)
|
|
* 3. Set the RETELL_AI_KEY environment variable with your Retell API key (must have webhook badge)
|
|
*
|
|
* This webhook will:
|
|
* - Verify webhook signature for security
|
|
* - Receive call_analyzed events from Retell AI
|
|
* - Charge credits based on the call cost from the user's or team's credit balance
|
|
* - Log all transactions for audit purposes
|
|
*/
|
|
async function handler(request: NextRequest) {
|
|
const rawBody = await request.text();
|
|
const body = JSON.parse(rawBody);
|
|
|
|
// Verify webhook signature
|
|
const signature = request.headers.get("x-retell-signature");
|
|
const apiKey = process.env.RETELL_AI_KEY;
|
|
|
|
if (!signature || !apiKey) {
|
|
log.error("Missing signature or API key for webhook verification");
|
|
return NextResponse.json(
|
|
{
|
|
error: "Unauthorized",
|
|
message: "Missing signature or API key",
|
|
},
|
|
{ status: 401 }
|
|
);
|
|
}
|
|
|
|
if (!Retell.verify(rawBody, apiKey, signature)) {
|
|
log.error("Invalid webhook signature");
|
|
return NextResponse.json(
|
|
{
|
|
error: "Unauthorized",
|
|
message: "Invalid signature",
|
|
},
|
|
{ status: 401 }
|
|
);
|
|
}
|
|
|
|
if (body.event !== "call_analyzed") {
|
|
return NextResponse.json(
|
|
{
|
|
success: true,
|
|
message: `No handling for ${body.event} for call ${body.call?.call_id ?? "unknown"}`,
|
|
},
|
|
{ status: 200 }
|
|
);
|
|
}
|
|
|
|
try {
|
|
const payload = RetellWebhookSchema.parse(body);
|
|
const callData = payload.call;
|
|
if (callData.direction === "inbound") {
|
|
return NextResponse.json(
|
|
{
|
|
success: true,
|
|
message: `Inbound calls are not charged or supported for now. Ignoring call ${callData.call_id}`,
|
|
},
|
|
{ status: 200 }
|
|
);
|
|
}
|
|
|
|
log.info(`Received Retell AI webhook: ${payload.event} for call ${callData.call_id}`);
|
|
|
|
const result = await handleCallAnalyzed(callData);
|
|
|
|
return NextResponse.json(
|
|
{
|
|
success: result?.success ?? true,
|
|
message: result?.message ?? `Processed ${payload.event} for call ${callData.call_id}`,
|
|
},
|
|
{ status: 200 }
|
|
);
|
|
} catch (error) {
|
|
log.error("Error processing Retell AI webhook:", safeStringify(error));
|
|
return NextResponse.json(
|
|
{
|
|
error: "Internal server error",
|
|
message: error instanceof Error ? error.message : "Unknown error",
|
|
},
|
|
// we need to return 200 to retell ai to avoid retries
|
|
{ status: 200 }
|
|
);
|
|
}
|
|
}
|
|
|
|
export const POST = defaultResponderForAppDir(handler);
|