Files
calendar/packages/features/tasker/tasks/executeAIPhoneCall.ts
T
Hariom BalharaandGitHub 50c5423ba7 fix: support underscores in workflow template variables with backward compatibility (#27571)
## What does this PR do?

Fixes an issue where underscores in form field identifiers were being stripped when converting to workflow template variables. According to the documentation, users should be able to use variables like `{COMPANY_NAME}` or `{MONTHLY_INFRASTRUCTURE_SPEND}`, but the regex in `formatIdentifierToVariable` was removing underscores, causing these variables to not match.

**The problem:**
- Form field identifier: `Company_Name` or `Monthly_Infrastructure_Spend`
- Expected variable: `{COMPANY_NAME}` or `{MONTHLY_INFRASTRUCTURE_SPEND}`
- Actual variable (before fix): `{COMPANYNAME}` or `{MONTHLYINFRASTRUCTURESPEND}`

**The fix:**
- Updated the regex from `[^a-zA-Z0-9 ]` to `[^a-zA-Z0-9_ ]` to preserve underscores in `formatIdentifierToVariable`
- Added a private `formatIdentifierToVariableLegacy` function that maintains the old behavior (strips underscores)
- Added `getVariableFormats` helper that returns both current and legacy formats for backward compatibility
- Added `convertResponsesToVariableFormats` helper in `executeAIPhoneCall.ts` to convert form responses to both variable formats
- Updated `customTemplate.ts` matching logic to support both variable formats
- Updated `executeAIPhoneCall.ts` to include both variable formats when building dynamic variables

This ensures existing templates using `{COMPANYNAME}` continue to work while new templates can use the documented `{COMPANY_NAME}` format.

## Mandatory Tasks (DO NOT REMOVE)

- [x] I have self-reviewed the code (A decent size PR without self-review might be rejected).
- [x] I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. N/A - no docs changes needed.
- [x] I confirm automated tests are in place that prove my fix is effective or that my feature works.

## How should this be tested?

1. Create a routing form with a field identifier containing underscores (e.g., `Company_Name`)
2. Create a workflow with a template using `{COMPANY_NAME}` (with underscore)
3. Submit the form and verify the variable is replaced correctly
4. Also test with `{COMPANYNAME}` (without underscore) to verify backward compatibility

**Automated tests added:**
- `customTemplate.test.ts` - 15 tests covering `formatIdentifierToVariable`, `getVariableFormats`, and template variable replacement
- `executeAIPhoneCall.test.ts` - 6 tests covering `convertResponsesToVariableFormats` function for form responses

## Human Review Checklist

- [ ] Verify the regex change `[^a-zA-Z0-9_ ]` correctly preserves underscores
- [ ] Verify backward compatibility: both `{COMPANY_NAME}` and `{COMPANYNAME}` should work for the same form field
- [ ] Verify `convertResponsesToVariableFormats` correctly generates both variable formats
- [ ] Review test coverage for edge cases (empty responses, undefined values)

---

Link to Devin run: https://app.devin.ai/sessions/9d5d90178714493086d94691865c3e07
Requested by: @hariombalhara
<!-- devin-review-badge-begin -->

---

<a href="https://app.devin.ai/review/calcom/cal.com/pull/27571">
  <picture>
    <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
    <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-02-12 17:21:42 +05:30

286 lines
10 KiB
TypeScript

import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
import dayjs from "@calcom/dayjs";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone";
import { handleInsufficientCredits } from "@calcom/features/ee/billing/helpers/handleInsufficientCredits";
import { getVariableFormats } from "@calcom/features/ee/workflows/lib/reminders/templates/customTemplate";
import { WorkflowReminderRepository } from "@calcom/features/ee/workflows/lib/repository/workflowReminder";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import {
getSubmitterEmail,
getSubmitterName,
} from "@calcom/features/tasker/tasks/triggerFormSubmittedNoEvent/formSubmissionValidation";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import { CreditUsageType } from "@calcom/prisma/enums";
interface ExecuteAIPhoneCallPayload {
workflowReminderId: number;
agentId: string;
fromNumber: string;
toNumber: string;
bookingUid: string | null;
userId: number | null;
teamId: number | null;
providerAgentId: string;
responses?: FORM_SUBMITTED_WEBHOOK_RESPONSES | null;
routedEventTypeId?: number | null;
}
const log = logger.getSubLogger({ prefix: [`[[executeAIPhoneCall] `] });
type BookingWithRelations = NonNullable<
NonNullable<
Awaited<ReturnType<typeof WorkflowReminderRepository.findWorkflowReminderForAIPhoneCallExecution>>
>["booking"]
>;
/**
* Converts responses to variable format entries with both current and legacy formats
* for backward compatibility.
* Accepts both FORM_SUBMITTED_WEBHOOK_RESPONSES and CalEventResponses types.
*/
export function convertResponsesToVariableFormats(
responses: Record<string, { value?: unknown }>
) {
return Object.fromEntries(
Object.entries(responses).flatMap(([key, value]) => {
const formats = getVariableFormats(key);
const valueStr = value.value?.toString() || "";
return formats.map((format) => [format, valueStr]);
})
);
}
function getVariablesFromFormResponse({
responses,
eventTypeId,
numberToCall,
}: {
responses: FORM_SUBMITTED_WEBHOOK_RESPONSES;
eventTypeId: number | null;
numberToCall: string;
}) {
const submittedEmail = getSubmitterEmail(responses);
const submittedName = getSubmitterName(responses);
return {
ATTENDEE_NAME: submittedName || "",
ATTENDEE_EMAIL: submittedEmail || "",
NUMBER_TO_CALL: numberToCall,
eventTypeId: eventTypeId?.toString() || "",
// Include custom form responses with both current and legacy variable formats for backward compatibility
...convertResponsesToVariableFormats(responses || {}),
};
}
function getVariablesFromBooking(booking: BookingWithRelations, numberToCall: string) {
const attendee = booking.attendees[0];
const timeZone = booking.user?.timeZone || attendee?.timeZone || "UTC";
const { responses } = getCalEventResponses({
bookingFields: booking.eventType?.bookingFields ?? null,
booking: {
...booking,
customInputs: null,
},
});
const attendeeNameWords = attendee?.name?.trim().split(" ") || [];
const attendeeFirstName = attendeeNameWords[0] || "";
const attendeeLastName =
attendeeNameWords.length > 1 ? attendeeNameWords[attendeeNameWords.length - 1] : "";
return {
EVENT_NAME: booking.eventType?.title || "",
EVENT_DATE: dayjs(booking.startTime).tz(timeZone).format("dddd, MMMM D, YYYY"),
EVENT_TIME: dayjs(booking.startTime).tz(timeZone).format("h:mm A"),
EVENT_END_TIME: dayjs(booking.endTime).tz(timeZone).format("h:mm A"),
TIMEZONE: timeZone,
LOCATION: booking.location || "",
ORGANIZER_NAME: booking.user?.name || "",
ATTENDEE_NAME: attendee?.name || "",
ATTENDEE_FIRST_NAME: attendeeFirstName,
ATTENDEE_LAST_NAME: attendeeLastName,
ATTENDEE_EMAIL: attendee?.email || "",
NUMBER_TO_CALL: numberToCall,
ATTENDEE_TIMEZONE: attendee?.timeZone || "",
ADDITIONAL_NOTES: booking.description || "",
EVENT_START_TIME_IN_ATTENDEE_TIMEZONE: dayjs(booking.startTime)
.tz(attendee?.timeZone || timeZone)
.format("h:mm A"),
EVENT_END_TIME_IN_ATTENDEE_TIMEZONE: dayjs(booking.endTime)
.tz(attendee?.timeZone || timeZone)
.format("h:mm A"),
// DO NOT REMOVE THIS FIELD. It is used for conditional tool routing in prompts
eventTypeId: booking.eventTypeId?.toString() || "",
// Include custom form responses with both current and legacy variable formats for backward compatibility
...convertResponsesToVariableFormats(responses || {}),
};
}
export async function executeAIPhoneCall(payload: string) {
let data: ExecuteAIPhoneCallPayload;
try {
data = JSON.parse(payload);
} catch (error) {
log.error("Failed to parse AI phone call payload", { error, payload });
throw new Error("Invalid JSON payload");
}
const featuresRepository = new FeaturesRepository(prisma);
const calAIVoiceAgents = await featuresRepository.checkIfFeatureIsEnabledGlobally("cal-ai-voice-agents");
if (!calAIVoiceAgents) {
log.warn("Cal.ai voice agents are disabled - skipping AI phone call");
return;
}
log.info(`Executing AI phone call for workflow reminder ${data.workflowReminderId}`, data);
try {
const workflowReminder = await WorkflowReminderRepository.findWorkflowReminderForAIPhoneCallExecution(
data.workflowReminderId
);
if (!workflowReminder || !workflowReminder.scheduled) {
log.warn(`Workflow reminder ${data.workflowReminderId} not found or not scheduled`);
throw new Error("Reminder not found or not scheduled");
}
if (data.userId || data.teamId) {
const { CreditService } = await import("@calcom/features/ee/billing/credit-service");
const creditService = new CreditService();
const hasCredits = await creditService.hasAvailableCredits({
userId: data.userId || undefined,
teamId: data.teamId || undefined,
});
if (!hasCredits) {
log.warn(`Insufficient credits for AI phone call for workflow reminder ${data.workflowReminderId}`, {
userId: data.userId,
teamId: data.teamId,
workflowReminderId: data.workflowReminderId,
bookingUid: workflowReminder.booking?.uid,
});
await handleInsufficientCredits({
userId: data.userId,
teamId: data.teamId,
creditFor: CreditUsageType.CAL_AI_PHONE_CALL,
prismaClient: prisma,
context: {
workflowReminderId: data.workflowReminderId,
bookingUid: workflowReminder.booking?.uid,
},
});
return;
}
}
const rateLimitIdentifier = data.teamId
? `executeAIPhoneCall:team-${data.teamId}`
: data.userId
? `executeAIPhoneCall:user-${data.userId}`
: null;
if (!rateLimitIdentifier) {
log.warn(`No rate limit identifier found for AI phone call. This should not happen.`, {
userId: data.userId,
teamId: data.teamId,
workflowReminderId: data.workflowReminderId,
});
throw new Error("No rate limit identifier found for AI phone call. This should not happen.");
}
// TODO: add better rate limiting for AI phone calls
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: rateLimitIdentifier,
});
// form triggers don't have a booking
const booking = workflowReminder.booking as BookingWithRelations | null;
const routingFormResponses = data.responses;
if (!booking && !routingFormResponses) {
log.warn(`No form responses or booking found for workflow reminder ${data.workflowReminderId}`);
throw new Error("No booking, response, or form responses found");
}
const numberToCall = data.toNumber;
if (!numberToCall) {
log.warn(`No phone number found in booking ${booking?.uid} or the routing form responses`);
throw new Error("No phone number found");
}
type VariablesType = {
ATTENDEE_EMAIL: string;
ATTENDEE_NAME: string;
NUMBER_TO_CALL: string;
eventTypeId: string;
} & Partial<ReturnType<typeof getVariablesFromBooking>>;
// Prefer response variables if present, else fall back to booking
let dynamicVariables: VariablesType | undefined;
if (routingFormResponses) {
const workflowStep = workflowReminder.workflowStep;
const eventTypeId =
data.routedEventTypeId ??
(workflowStep?.workflow.trigger === "FORM_SUBMITTED"
? workflowStep.agent?.outboundEventTypeId
: null);
if (!eventTypeId) {
log.warn(
`Form not routed to an event type and no event type id found for workflow reminder ${data.workflowReminderId}`
);
return;
}
dynamicVariables = getVariablesFromFormResponse({
responses: routingFormResponses,
eventTypeId,
numberToCall,
});
} else if (booking) {
dynamicVariables = getVariablesFromBooking(booking, numberToCall);
}
if (!dynamicVariables) return;
const aiService = createDefaultAIPhoneServiceProvider();
await aiService.updateToolsFromAgentId(data.providerAgentId, {
eventTypeId: dynamicVariables.eventTypeId ? Number(dynamicVariables.eventTypeId) : null,
timeZone: dynamicVariables.TIMEZONE || "UTC",
userId: data.userId,
teamId: data.teamId,
});
const call = await aiService.createPhoneCall({
fromNumber: data.fromNumber,
toNumber: numberToCall,
dynamicVariables,
});
log.info("AI phone call created successfully:", call);
await WorkflowReminderRepository.updateWorkflowReminderReferenceAndScheduled(data.workflowReminderId, {
referenceId: call.call_id,
scheduled: true,
});
log.info(`AI phone call executed successfully for workflow reminder ${data.workflowReminderId}`, {
callId: call.call_id,
agentId: data.agentId,
fromNumber: data.fromNumber,
toNumber: numberToCall,
bookingUid: data.bookingUid,
workflowReminderId: data.workflowReminderId,
});
} catch (error) {
log.error("Error executing AI phone call:", error);
throw error;
}
}