chore(call-recorder): drop FAILED status schema bridge (#22134)

Cleanup of the call-recorder data layer + the SDK 2.16 bump and its
fallout, now that the `FAILED_UNKNOWN → FAILED` rename shipped in
`twenty/v2.16.0` (#22062).

- **Drop the schema bridge.** `executeCurrentSchemaMutation` and the
integration-test compatibility filter existed only to work around
servers exposing `FAILED_UNKNOWN`. Deleted the bridge;
`updateCallRecording` / `completeCallRecordingIngestion` call
`client.mutation(...)` directly. Integration test iterates all
`CallRecordingStatus` values.
- **Bump SDK** `twenty-sdk` / `twenty-client-sdk` to `2.16.0`.
- **One export per file.** Move `CallRecordingUpdateFields` to its own
type file; extract the duplicated media-file shape into
`CallRecordingMediaFile`.
- **Migrate the Recall webhook to `serverRouteTriggerSettings`** (2.16
dropped `serverWebhookTriggerSettings` + its declarative
`workspaceIdResolver`). The webhook is now a **resolver**
(`recall-webhook`) that verifies the Svix signature, reads
`twentyWorkspaceId` from the Recall bot metadata, and returns `{
workspaceId, targetLogicFunctionUniversalIdentifier, payload }`; the
platform dispatches to a new **target** function
(`process-recall-webhook`) in the resolved workspace, where
`CoreApiClient` is workspace-scoped. Resolver UID/route unchanged, so
the registered Recall endpoint URL stays valid. Failures now throw →
HTTP 500 (Svix retries) instead of returning 401/400.

Verified: typecheck, 213 unit tests, oxlint, oxfmt all green. **Not yet
verified end-to-end against a live server** — call-recorder is the first
app on `serverRouteTriggerSettings`, so a real Recall webhook should be
tested through the resolver→target path before relying on it.
This commit is contained in:
nitin
2026-06-25 14:36:58 +05:30
committed by GitHub
parent dc371ef6e7
commit db687c7407
37 changed files with 663 additions and 399 deletions
@@ -106,8 +106,8 @@ What this app intentionally does **not** do in v1:
| No bot joined a meeting | **Recording Bot** was Off, the event had no conference link, it wasn't synced from a connected calendar, or `RECALL_API_KEY` isn't set | Confirm the event is On, upcoming, has a video link, and came from a synced calendar; admin: confirm `RECALL_API_KEY` is set |
| Recording never reaches `COMPLETED` | A Recall webhook was missed, or only one of audio/video was produced | The reconciliation job pulls the latest status from Recall within a few minutes; if it is marked `FAILED`, inspect the bot in the Recall dashboard |
| Transcript empty, or marked pending/failed | Recall hasn't finished async transcription yet, or transcription failed for that call | Wait for the reconciliation job to ingest the transcript; a persistent failure leaves a marker in the transcript |
| Webhook rejected with `401` (Recall keeps retrying) | `RECALL_WEBHOOK_SECRET` doesn't match the Recall endpoint's signing secret | Re-copy the `whsec_…` secret from the Recall webhook endpoint into the `RECALL_WEBHOOK_SECRET` server variable |
| Webhook rejected with `500` about the secret | `RECALL_WEBHOOK_SECRET` is not set | Admin: set it on the application registration |
| Webhook rejected with `500` (`Invalid webhook signature`, Recall keeps retrying) | `RECALL_WEBHOOK_SECRET` doesn't match the Recall endpoint's signing secret | Re-copy the `whsec_…` secret from the Recall webhook endpoint into the `RECALL_WEBHOOK_SECRET` server variable |
| Webhook rejected with `500` (`RECALL_WEBHOOK_SECRET … not set`) | `RECALL_WEBHOOK_SECRET` is not set | Admin: set it on the application registration |
| Bot left almost immediately | No one was admitted before the lobby / no-one-joined timeout, or everyone left | Adjust `CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS` / `CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS` if too aggressive |
| Bot joined a meeting you didn't want recorded | Recording is on by default | Set the event's **Recording Bot** field to Off; the scheduled bot is canceled |
@@ -31,8 +31,8 @@
"oxlint": "^0.16.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"twenty-client-sdk": "2.15.0",
"twenty-sdk": "2.15.0",
"twenty-client-sdk": "2.16.0",
"twenty-sdk": "2.16.0",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^4.1.9"
@@ -5,10 +5,6 @@ import { describe, expect, it } from 'vitest';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/application-universal-identifier';
import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status';
import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
import {
executeCurrentSchemaMutation,
type CurrentSchemaUpdateCallRecordingMutation,
} from 'src/logic-functions/data/execute-current-schema-mutation.util';
describe('App installation', () => {
it('should find the installed app in the applications list', async () => {
@@ -58,30 +54,16 @@ describe('CallRecording status contract', () => {
}
expect(serverCallRecordingStatuses).toEqual(
expect.arrayContaining([
CallRecordingStatus.SCHEDULED,
CallRecordingStatus.JOINING,
CallRecordingStatus.RECORDING,
CallRecordingStatus.PROCESSING,
CallRecordingStatus.COMPLETED,
]),
expect.arrayContaining(Object.values(CallRecordingStatus)),
);
// TODO: Remove this compatibility filter once the released server/SDK
// exposes FAILED instead of FAILED_UNKNOWN.
const statusesAcceptedByCurrentServer = Object.values(
CallRecordingStatus,
).filter((status) => serverCallRecordingStatuses.includes(status));
for (const status of statusesAcceptedByCurrentServer) {
const mutation = {
for (const status of Object.values(CallRecordingStatus)) {
const updated = await client.mutation({
updateCallRecording: {
__args: { id: callRecordingId, data: { status } },
status: true,
},
} satisfies CurrentSchemaUpdateCallRecordingMutation;
const updated = await executeCurrentSchemaMutation(client, mutation);
});
expect(updated.updateCallRecording?.status).toBe(status);
}
@@ -0,0 +1,2 @@
export const PROCESS_RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'13d9c427-447e-494a-8d3c-1af5d0bacb82';
@@ -0,0 +1,62 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import processRecallWebhookLogicFunction, {
processRecallWebhookHandler,
} from 'src/logic-functions/process-recall-webhook';
const handleRecallWebhookMock = vi.hoisted(() => vi.fn());
const coreApiClientMock = vi.hoisted(() => vi.fn());
vi.mock('src/logic-functions/flows/handle-recall-webhook.util', () => ({
handleRecallWebhook: handleRecallWebhookMock,
}));
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: coreApiClientMock,
}));
const buildRecordingDoneWebhookBody = () => ({
event: 'recording.done',
data: {
bot: {
id: 'recall-bot-1',
metadata: {
twentyWorkspaceId: '123e4567-e89b-12d3-a456-426614174000',
twentyCallRecordingId: 'call-recording-1',
},
},
recording: { id: 'recall-recording-1' },
},
});
describe('process-recall-webhook', () => {
beforeEach(() => {
handleRecallWebhookMock.mockReset();
handleRecallWebhookMock.mockResolvedValue({ status: 'updated' });
coreApiClientMock.mockReset();
});
it('declares no external trigger so it only runs when dispatched by the resolver', () => {
expect(processRecallWebhookLogicFunction.success).toBe(true);
expect(
'serverRouteTriggerSettings' in processRecallWebhookLogicFunction.config,
).toBe(false);
expect(
processRecallWebhookLogicFunction.config.httpRouteTriggerSettings,
).toBeUndefined();
});
it('forwards the resolved payload to handleRecallWebhook with a workspace-scoped client', async () => {
const body = buildRecordingDoneWebhookBody();
const result = await processRecallWebhookHandler(body);
expect(coreApiClientMock).toHaveBeenCalledTimes(1);
expect(handleRecallWebhookMock).toHaveBeenCalledTimes(1);
expect(handleRecallWebhookMock).toHaveBeenCalledWith({
client: coreApiClientMock.mock.instances[0],
body,
});
expect(result).toEqual({ status: 'updated' });
});
});
@@ -2,12 +2,12 @@ import { createHmac } from 'crypto';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { PROCESS_RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/process-recall-webhook-logic-function-universal-identifier';
import recallWebhookLogicFunction, {
recallWebhookRouteHandler,
} from 'src/logic-functions/recall-webhook';
const getApplicationVariableValueMock = vi.hoisted(() => vi.fn());
const handleRecallWebhookMock = vi.hoisted(() => vi.fn());
vi.mock(
'src/logic-functions/utils/get-application-variable-value.util',
@@ -16,17 +16,10 @@ vi.mock(
}),
);
vi.mock('src/logic-functions/flows/handle-recall-webhook.util', () => ({
handleRecallWebhook: handleRecallWebhookMock,
}));
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: vi.fn(),
}));
const SECRET_BYTES = Buffer.from('entry-test-secret');
const SECRET = `whsec_${SECRET_BYTES.toString('base64')}`;
const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000';
const CALL_RECORDING_ID = 'call-recording-1';
type RecallWebhookRoutePayload = Parameters<
typeof recallWebhookRouteHandler
@@ -61,6 +54,7 @@ const buildRecordingDoneWebhookBody = () => ({
id: 'recall-bot-1',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: CALL_RECORDING_ID,
},
},
recording: {
@@ -71,25 +65,26 @@ const buildRecordingDoneWebhookBody = () => ({
describe('recallWebhookRouteHandler', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {});
getApplicationVariableValueMock.mockReset();
getApplicationVariableValueMock.mockReturnValue(SECRET);
handleRecallWebhookMock.mockReset();
handleRecallWebhookMock.mockResolvedValue({ status: 'updated' });
});
it('declares a server webhook resolver for Recall bot workspace metadata', () => {
it('declares a server route trigger that forwards the webhook signature headers', () => {
expect(recallWebhookLogicFunction.success).toBe(true);
expect(
recallWebhookLogicFunction.config.httpRouteTriggerSettings,
).toBeUndefined();
expect(
recallWebhookLogicFunction.config.serverWebhookTriggerSettings,
'serverRouteTriggerSettings' in recallWebhookLogicFunction.config,
).toBe(true);
if (!('serverRouteTriggerSettings' in recallWebhookLogicFunction.config)) {
throw new Error('Expected a server route trigger');
}
expect(
recallWebhookLogicFunction.config.serverRouteTriggerSettings,
).toEqual({
workspaceIdResolver: {
source: 'body',
path: 'data.bot.metadata.twentyWorkspaceId',
},
forwardedRequestHeaders: [
'webhook-id',
'webhook-timestamp',
@@ -101,83 +96,73 @@ describe('recallWebhookRouteHandler', () => {
});
});
it('responds 500 when the webhook secret is not configured', async () => {
it('throws when the webhook secret is not configured', () => {
getApplicationVariableValueMock.mockReturnValue(undefined);
const result = await recallWebhookRouteHandler(
buildRoutePayload({ rawBody: '{}', body: {} }),
);
expect(result).toMatchObject({
__twentyHttpResponse: true,
status: 500,
body: {
error: expect.stringContaining('RECALL_WEBHOOK_SECRET'),
},
});
expect(() =>
recallWebhookRouteHandler(buildRoutePayload({ rawBody: '{}', body: {} })),
).toThrow('RECALL_WEBHOOK_SECRET');
});
it('responds 500 when the raw body is not forwarded', async () => {
const result = await recallWebhookRouteHandler(
buildRoutePayload({ body: {} }),
);
expect(result).toMatchObject({
__twentyHttpResponse: true,
status: 500,
body: {
error: expect.stringContaining('Raw request body'),
},
});
it('throws when the raw body is not forwarded', () => {
expect(() =>
recallWebhookRouteHandler(buildRoutePayload({ body: {} })),
).toThrow('Raw request body');
});
it('responds 401 when the signature is invalid', async () => {
const result = await recallWebhookRouteHandler(
buildRoutePayload({
rawBody: '{}',
body: {},
headers: {
'webhook-id': 'msg_entry_test',
'webhook-timestamp': Math.floor(Date.now() / 1000).toString(),
'webhook-signature': 'v1,not-a-real-signature',
},
}),
);
expect(result).toMatchObject({
__twentyHttpResponse: true,
status: 401,
body: {
error: expect.stringContaining('Invalid webhook signature'),
},
});
it('throws when the signature is invalid', () => {
expect(() =>
recallWebhookRouteHandler(
buildRoutePayload({
rawBody: '{}',
body: {},
headers: {
'webhook-id': 'msg_entry_test',
'webhook-timestamp': Math.floor(Date.now() / 1000).toString(),
'webhook-signature': 'v1,not-a-real-signature',
},
}),
),
).toThrow('Invalid webhook signature');
});
it('responds 400 when a correctly signed payload is empty', async () => {
it('throws when a correctly signed payload is empty', () => {
const rawBody = 'null';
const result = await recallWebhookRouteHandler(
buildRoutePayload({
rawBody,
body: null,
headers: buildSignedHeaders(rawBody),
}),
);
expect(result).toMatchObject({
__twentyHttpResponse: true,
status: 400,
body: {
error: 'Webhook payload was empty',
},
});
expect(() =>
recallWebhookRouteHandler(
buildRoutePayload({
rawBody,
body: null,
headers: buildSignedHeaders(rawBody),
}),
),
).toThrow('Webhook payload was empty');
});
it('dispatches a correctly signed payload to the handler', async () => {
it('throws when the workspace id is missing from the bot metadata', () => {
const body = {
event: 'recording.done',
data: { bot: { id: 'recall-bot-1' } },
};
const rawBody = JSON.stringify(body);
expect(() =>
recallWebhookRouteHandler(
buildRoutePayload({
rawBody,
body,
headers: buildSignedHeaders(rawBody),
}),
),
).toThrow('workspace id');
});
it('resolves the target workspace for a correctly signed payload', () => {
const body = buildRecordingDoneWebhookBody();
const rawBody = JSON.stringify(body);
const result = await recallWebhookRouteHandler(
const result = recallWebhookRouteHandler(
buildRoutePayload({
rawBody,
body,
@@ -185,10 +170,11 @@ describe('recallWebhookRouteHandler', () => {
}),
);
expect(handleRecallWebhookMock).toHaveBeenCalledTimes(1);
expect(handleRecallWebhookMock).toHaveBeenCalledWith(
expect.objectContaining({ body }),
);
expect(result).toEqual({ status: 'updated' });
expect(result).toEqual({
workspaceId: WORKSPACE_ID,
targetLogicFunctionUniversalIdentifier:
PROCESS_RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
payload: body,
});
});
});
@@ -2,16 +2,12 @@ import { type CoreApiClient } from 'twenty-client-sdk/core';
import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
import { NON_TERMINAL_CALL_RECORDING_STATUSES } from 'src/logic-functions/constants/non-terminal-call-recording-statuses';
import {
executeCurrentSchemaMutation,
type CurrentSchemaUpdateCallRecordingsMutation,
} from 'src/logic-functions/data/execute-current-schema-mutation.util';
export const completeCallRecordingIngestion = async (
client: CoreApiClient,
{ id }: { id: string },
): Promise<boolean> => {
const mutation = {
const result = await client.mutation({
updateCallRecordings: {
__args: {
filter: {
@@ -22,9 +18,7 @@ export const completeCallRecordingIngestion = async (
},
id: true,
},
} satisfies CurrentSchemaUpdateCallRecordingsMutation;
const result = await executeCurrentSchemaMutation(client, mutation);
});
return (result.updateCallRecordings ?? []).length > 0;
};
@@ -1,54 +0,0 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { type CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
import { type CallRecordingUpdateFields } from 'src/logic-functions/data/update-call-recording.util';
type CurrentSchemaMutationFunction = (
mutation: CurrentSchemaMutation,
) => Promise<CurrentSchemaMutationResult>;
export type CurrentSchemaUpdateCallRecordingMutation = {
updateCallRecording: {
__args: {
id: string;
data: CallRecordingUpdateFields;
};
id?: true;
status?: true;
};
};
export type CurrentSchemaUpdateCallRecordingsMutation = {
updateCallRecordings: {
__args: {
filter: {
id: { eq: string };
status?: { in: CallRecordingStatus[] };
};
data: Pick<CallRecordingUpdateFields, 'status'>;
};
id?: true;
};
};
type CurrentSchemaMutation =
| CurrentSchemaUpdateCallRecordingMutation
| CurrentSchemaUpdateCallRecordingsMutation;
type CurrentSchemaMutationResult = {
updateCallRecording?: { id?: string; status?: string | null } | null;
updateCallRecordings?: { id?: string }[] | null;
};
// TODO: Remove this bridge once the released SDK includes the current
// CallRecording schema with FAILED and callRecorderFailureReason.
export const executeCurrentSchemaMutation = (
client: CoreApiClient,
mutation: CurrentSchemaMutation,
): Promise<CurrentSchemaMutationResult> => {
const currentSchemaClient = client as {
mutation: CurrentSchemaMutationFunction;
};
return currentSchemaClient.mutation(mutation);
};
@@ -1,28 +1,6 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { type CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status';
import { type CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
import {
executeCurrentSchemaMutation,
type CurrentSchemaUpdateCallRecordingMutation,
} from 'src/logic-functions/data/execute-current-schema-mutation.util';
export type CallRecordingUpdateFields = Partial<{
// null clears a previously synced title when the calendar title disappears.
title: string | null;
status: CallRecordingStatus;
recordingRequestStatus: CallRecordingRequestStatus;
startedAt: string;
endedAt: string;
calendarEventId: string;
// null clears stale app-owned state on cancel/eject or reschedule.
externalBotId: string | null;
externalRecordingId: string;
callRecorderFailureReason: string | null;
transcript: Record<string, unknown>;
audio: { fileId: string; label: string }[];
video: { fileId: string; label: string }[];
}>;
import { type CallRecordingUpdateFields } from 'src/logic-functions/types/call-recording-update-fields.type';
export const updateCallRecording = async (
client: CoreApiClient,
@@ -34,7 +12,7 @@ export const updateCallRecording = async (
data: CallRecordingUpdateFields;
},
): Promise<void> => {
const mutation = {
await client.mutation({
updateCallRecording: {
__args: {
id,
@@ -42,7 +20,5 @@ export const updateCallRecording = async (
},
id: true,
},
} satisfies CurrentSchemaUpdateCallRecordingMutation;
await executeCurrentSchemaMutation(client, mutation);
});
};
@@ -1,21 +0,0 @@
import { type MeetingRecording } from 'src/logic-functions/types/meeting-recording.type';
import { type RecallBotMetadata } from 'src/logic-functions/types/recall-bot-metadata.type';
import { computeRealMeetingKey } from 'src/logic-functions/domain/compute-real-meeting-key.util';
export const buildRecallBotMetadata = ({
callRecording,
calendarEvent,
workspaceId,
}: MeetingRecording & { workspaceId: string }): RecallBotMetadata => {
return {
twentyWorkspaceId: workspaceId,
twentyCallRecordingId: callRecording.id,
twentyCalendarEventId: calendarEvent.id,
twentyRealMeetingKey: computeRealMeetingKey({
calendarEventId: calendarEvent.id,
conferenceLinkUrl: calendarEvent.conferenceLinkUrl,
iCalUid: calendarEvent.iCalUid,
startsAt: calendarEvent.startsAt,
}),
};
};
@@ -0,0 +1,12 @@
import { type RecallRoutingMetadata } from 'src/logic-functions/types/recall-routing-metadata.type';
export const buildRecallRoutingMetadata = ({
callRecordingId,
workspaceId,
}: {
callRecordingId: string;
workspaceId: string;
}): RecallRoutingMetadata => ({
twentyWorkspaceId: workspaceId,
twentyCallRecordingId: callRecordingId,
});
@@ -2,7 +2,7 @@ import { CallRecordingStatus } from 'src/logic-functions/constants/call-recordin
import { type FilesFieldValue } from 'src/logic-functions/types/files-field-value.type';
import { computeCallRecordingCharge } from 'src/logic-functions/domain/compute-call-recording-charge.util';
import { isCallRecordingIngestionComplete } from 'src/logic-functions/domain/is-call-recording-ingestion-complete.util';
import { type CallRecordingUpdateFields } from 'src/logic-functions/data/update-call-recording.util';
import { type CallRecordingUpdateFields } from 'src/logic-functions/types/call-recording-update-fields.type';
export const shouldCompleteCallRecordingIngestion = ({
current,
@@ -160,7 +160,6 @@ describe('convergeDivergedCallRecordings', () => {
});
expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({
externalRecordingId: 'recall-recording-1',
callRecordingId: 'call-recording-1',
});
expect(client.mutations).toEqual([
expect.objectContaining({
@@ -497,7 +496,6 @@ describe('convergeDivergedCallRecordings', () => {
expect(createAsyncRecallTranscriptMock).toHaveBeenCalledTimes(1);
expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({
externalRecordingId: 'recall-recording-1',
callRecordingId: 'call-recording-1',
});
expect(client.mutations).toEqual([
{
@@ -12,6 +12,7 @@ const buildRecordingDoneWebhookBody = () => ({
id: 'recall-bot-1',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-1',
},
},
recording: {
@@ -190,6 +191,7 @@ describe('handleRecallWebhook', () => {
bot: {
id: 'recall-bot-1',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-1',
},
},
@@ -237,6 +239,7 @@ describe('handleRecallWebhook', () => {
bot: {
id: 'recall-bot-1',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-1',
},
},
@@ -272,6 +275,7 @@ describe('handleRecallWebhook', () => {
bot: {
id: 'recall-bot-1',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-1',
},
},
@@ -321,6 +325,7 @@ describe('handleRecallWebhook', () => {
bot: {
id: 'recall-bot-1',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-current',
},
},
@@ -410,6 +415,7 @@ describe('handleRecallWebhook', () => {
bot: {
id: 'recall-bot-1',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-1',
},
},
@@ -457,6 +463,7 @@ describe('handleRecallWebhook', () => {
bot: {
id: 'recall-bot-1',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-1',
},
},
@@ -504,6 +511,7 @@ describe('handleRecallWebhook', () => {
bot: {
id: 'recall-bot-1',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-1',
},
},
@@ -545,6 +553,7 @@ describe('handleRecallWebhook', () => {
bot: {
id: 'recall-bot-1',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-1',
},
},
@@ -591,6 +600,7 @@ describe('handleRecallWebhook', () => {
bot: {
id: 'recall-bot-1',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-1',
},
},
@@ -632,6 +642,7 @@ describe('handleRecallWebhook', () => {
bot: {
id: 'recall-bot-1',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-1',
},
},
@@ -668,6 +679,7 @@ describe('handleRecallWebhook', () => {
bot: {
id: 'recall-bot-1',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-1',
},
},
@@ -696,6 +708,7 @@ describe('handleRecallWebhook', () => {
data: {
bot: {
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-deleted',
},
},
@@ -755,7 +768,6 @@ describe('handleRecallWebhook', () => {
expect(createAsyncRecallTranscriptMock).toHaveBeenCalledTimes(1);
expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({
externalRecordingId: 'recall-recording-1',
callRecordingId: 'call-recording-1',
});
expect(client.mutations).toEqual([
{
@@ -841,6 +853,7 @@ describe('handleRecallWebhook', () => {
bot: {
id: 'recall-bot-1',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-1',
},
},
@@ -856,7 +869,6 @@ describe('handleRecallWebhook', () => {
});
expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({
externalRecordingId: 'recall-recording-9',
callRecordingId: 'call-recording-1',
});
expect(client.mutations).toEqual([
expect.objectContaining({
@@ -954,7 +966,6 @@ describe('handleRecallWebhook', () => {
expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({
externalRecordingId: 'recall-recording-1',
callRecordingId: 'call-recording-1',
});
expect(client.mutations).toEqual([
expect.objectContaining({
@@ -1070,6 +1081,7 @@ describe('handleRecallWebhook', () => {
bot: {
id: 'recall-bot-1',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-1',
},
},
@@ -1150,6 +1162,7 @@ describe('handleRecallWebhook', () => {
bot: {
id: 'recall-bot-1',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-1',
},
},
@@ -1209,6 +1222,7 @@ describe('handleRecallWebhook', () => {
bot: {
id: 'recall-bot-1',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-1',
},
},
@@ -1263,6 +1277,7 @@ describe('handleRecallWebhook', () => {
bot: {
id: 'recall-bot-1',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-1',
},
},
@@ -265,9 +265,6 @@ describe('reconcileCallRecorderForCalendarEventIds', () => {
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: buildCustomerSyncCallRecordingId(),
twentyCalendarEventId: 'calendar-event-1',
twentyRealMeetingKey:
'link:meet.example.com/customer-sync:2026-01-01T13:00:00.000Z',
},
});
});
@@ -416,9 +413,6 @@ describe('reconcileCallRecorderForCalendarEventIds', () => {
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: buildCustomerSyncCallRecordingId(),
twentyCalendarEventId: 'calendar-event-1',
twentyRealMeetingKey:
'link:meet.example.com/customer-sync:2026-01-01T13:00:00.000Z',
},
});
});
@@ -23,10 +23,8 @@ import { persistCallRecordingProgress } from 'src/logic-functions/flows/persist-
import { reconcileCallRecordingTranscriptArtifact } from 'src/logic-functions/flows/reconcile-call-recording-transcript-artifact.util';
import { type ConvergeDivergedCallRecordingsResult } from 'src/logic-functions/flows/converge-diverged-call-recordings-result.type';
import { shouldCompleteCallRecordingIngestion } from 'src/logic-functions/domain/should-complete-call-recording-ingestion.util';
import {
updateCallRecording,
type CallRecordingUpdateFields,
} from 'src/logic-functions/data/update-call-recording.util';
import { updateCallRecording } from 'src/logic-functions/data/update-call-recording.util';
import { type CallRecordingUpdateFields } from 'src/logic-functions/types/call-recording-update-fields.type';
const CONVERGENCE_LOOKBACK_DAYS = 7;
@@ -3,7 +3,7 @@ import { type CoreApiClient } from 'twenty-client-sdk/core';
import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status';
import { type MeetingRecording } from 'src/logic-functions/types/meeting-recording.type';
import { buildRecallBotMetadata } from 'src/logic-functions/domain/build-recall-bot-metadata.util';
import { buildRecallRoutingMetadata } from 'src/logic-functions/domain/build-recall-routing-metadata.util';
import { computeRecallBotJoinAt } from 'src/logic-functions/domain/compute-recall-bot-join-at.util';
import { findCallRecordingsByIds } from 'src/logic-functions/data/find-call-recordings-by-ids.util';
import { getCurrentWorkspaceId } from 'src/logic-functions/data/get-current-workspace-id.util';
@@ -50,9 +50,8 @@ export const ensureCallRecorder = async (
const scheduleResult = await scheduleRecallBot({
meetingUrl,
joinAt,
metadata: buildRecallBotMetadata({
callRecording,
calendarEvent,
metadata: buildRecallRoutingMetadata({
callRecordingId: callRecording.id,
workspaceId,
}),
});
@@ -21,10 +21,8 @@ import {
import { parseTranscriptMarker } from 'src/logic-functions/domain/parse-transcript-marker.util';
import { persistCallRecordingProgress } from 'src/logic-functions/flows/persist-call-recording-progress.util';
import { reconcileCallRecordingTranscriptArtifact } from 'src/logic-functions/flows/reconcile-call-recording-transcript-artifact.util';
import {
updateCallRecording,
type CallRecordingUpdateFields,
} from 'src/logic-functions/data/update-call-recording.util';
import { updateCallRecording } from 'src/logic-functions/data/update-call-recording.util';
import { type CallRecordingUpdateFields } from 'src/logic-functions/types/call-recording-update-fields.type';
type MatchedCallRecording = {
id: string;
@@ -5,7 +5,8 @@ import { CALL_RECORDING_AUDIO_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/c
import { CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-video-field-universal-identifier';
import { extractRecallMediaUrls } from 'src/logic-functions/recall-api/extract-recall-media-urls.util';
import { getRecallRecording } from 'src/logic-functions/recall-api/get-recall-recording.util';
import { type CallRecordingUpdateFields } from 'src/logic-functions/data/update-call-recording.util';
import { type CallRecordingMediaFile } from 'src/logic-functions/types/call-recording-media-file.type';
import { type CallRecordingUpdateFields } from 'src/logic-functions/types/call-recording-update-fields.type';
type CallRecordingMediaUpdateFields = Pick<
CallRecordingUpdateFields,
@@ -88,7 +89,7 @@ const ingestMediaArtifact = async ({
url: string;
fileName: string;
fieldMetadataUniversalIdentifier: string;
}): Promise<{ fileId: string; label: string }[] | undefined> => {
}): Promise<CallRecordingMediaFile[] | undefined> => {
try {
const { buffer, contentType } = await downloadMediaFile(url);
const uploadedFile = await metadataClient.uploadFile(
@@ -3,10 +3,8 @@ import { type CoreApiClient } from 'twenty-client-sdk/core';
import { type FilesFieldValue } from 'src/logic-functions/types/files-field-value.type';
import { completeAndChargeCallRecording } from 'src/logic-functions/flows/complete-and-charge-call-recording.util';
import { shouldCompleteCallRecordingIngestion } from 'src/logic-functions/domain/should-complete-call-recording-ingestion.util';
import {
updateCallRecording,
type CallRecordingUpdateFields,
} from 'src/logic-functions/data/update-call-recording.util';
import { updateCallRecording } from 'src/logic-functions/data/update-call-recording.util';
import { type CallRecordingUpdateFields } from 'src/logic-functions/types/call-recording-update-fields.type';
type PersistCallRecordingProgressCurrent = {
status?: string;
@@ -23,10 +23,8 @@ import { findCallRecordingsByCalendarEventIds } from 'src/logic-functions/data/f
import { findCallRecordingsByIds } from 'src/logic-functions/data/find-call-recordings-by-ids.util';
import { getUniqueSortedIds } from 'src/logic-functions/utils/get-unique-sorted-ids.util';
import { rescheduleCallRecordingBot } from 'src/logic-functions/flows/reschedule-call-recording-bot.util';
import {
updateCallRecording,
type CallRecordingUpdateFields,
} from 'src/logic-functions/data/update-call-recording.util';
import { updateCallRecording } from 'src/logic-functions/data/update-call-recording.util';
import { type CallRecordingUpdateFields } from 'src/logic-functions/types/call-recording-update-fields.type';
export const reconcileCallRecorderForCalendarEventIds = async ({
client,
@@ -1,4 +1,4 @@
import { type CallRecordingUpdateFields } from 'src/logic-functions/data/update-call-recording.util';
import { type CallRecordingUpdateFields } from 'src/logic-functions/types/call-recording-update-fields.type';
type CallRecordingTranscriptArtifactUpdateFields = Pick<
CallRecordingUpdateFields,
@@ -68,7 +68,6 @@ export const reconcileCallRecordingTranscriptArtifact = async ({
) {
const createResult = await createAsyncRecallTranscript({
externalRecordingId,
callRecordingId,
});
if (!createResult.ok) {
@@ -2,7 +2,7 @@ import { isUndefined } from '@sniptt/guards';
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { type MeetingRecording } from 'src/logic-functions/types/meeting-recording.type';
import { buildRecallBotMetadata } from 'src/logic-functions/domain/build-recall-bot-metadata.util';
import { buildRecallRoutingMetadata } from 'src/logic-functions/domain/build-recall-routing-metadata.util';
import { computeRecallBotJoinAt } from 'src/logic-functions/domain/compute-recall-bot-join-at.util';
import { getCurrentWorkspaceId } from 'src/logic-functions/data/get-current-workspace-id.util';
import { rescheduleRecallBot } from 'src/logic-functions/recall-api/reschedule-recall-bot.util';
@@ -43,9 +43,8 @@ export const rescheduleCallRecordingBot = async (
externalBotId,
meetingUrl,
joinAt,
metadata: buildRecallBotMetadata({
callRecording,
calendarEvent,
metadata: buildRecallRoutingMetadata({
callRecordingId: callRecording.id,
workspaceId,
}),
});
@@ -0,0 +1,23 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk/define';
import { PROCESS_RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/process-recall-webhook-logic-function-universal-identifier';
import { handleRecallWebhook } from 'src/logic-functions/flows/handle-recall-webhook.util';
import { type RecallWebhookBody } from 'src/logic-functions/recall-api/parse-recall-webhook-event.util';
// Dispatched by the recall-webhook resolver; runs in the resolved workspace so the client is workspace-scoped.
export const processRecallWebhookHandler = (body: RecallWebhookBody) =>
handleRecallWebhook({
client: new CoreApiClient(),
body,
});
export default defineLogicFunction({
universalIdentifier:
PROCESS_RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'process-recall-webhook',
description:
'Updates the matching CallRecording lifecycle status from a verified Recall.ai webhook event.',
timeoutSeconds: 30,
handler: processRecallWebhookHandler,
});
@@ -13,6 +13,10 @@ import { CALL_RECORDER_RECORDING_RETENTION_HOURS_ENV_VAR_NAME } from 'src/logic-
const getRecallApiConfigMock = vi.hoisted(() => vi.fn());
const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000';
const RECALL_ROUTING_METADATA = {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-id',
};
vi.mock('src/logic-functions/recall-api/get-recall-api-config.util', () => ({
getRecallApiConfig: getRecallApiConfigMock,
@@ -45,12 +49,7 @@ describe('recall bot api', () => {
const result = await scheduleRecallBot({
meetingUrl: 'https://meet.google.com/abc-defg-hij',
joinAt: '2026-01-01T13:00:00.000Z',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-id',
twentyCalendarEventId: 'calendar-event-id',
twentyRealMeetingKey: 'meeting-key',
},
metadata: RECALL_ROUTING_METADATA,
});
expect(result).toEqual({ ok: true, externalBotId: 'recall-bot-id' });
@@ -73,12 +72,7 @@ describe('recall bot api', () => {
audio_mixed_mp3: {},
retention: { type: 'timed', hours: 166 },
},
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-id',
twentyCalendarEventId: 'calendar-event-id',
twentyRealMeetingKey: 'meeting-key',
},
metadata: RECALL_ROUTING_METADATA,
});
});
@@ -88,12 +82,7 @@ describe('recall bot api', () => {
const result = await scheduleRecallBot({
meetingUrl: 'https://meet.google.com/abc-defg-hij',
joinAt: '2026-01-01T13:00:00.000Z',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-id',
twentyCalendarEventId: 'calendar-event-id',
twentyRealMeetingKey: 'meeting-key',
},
metadata: RECALL_ROUTING_METADATA,
});
expect(result).toEqual({ ok: true, externalBotId: 'recall-bot-id' });
@@ -113,12 +102,7 @@ describe('recall bot api', () => {
const result = await scheduleRecallBot({
meetingUrl: 'https://meet.google.com/abc-defg-hij',
joinAt: '2026-01-01T13:00:00.000Z',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-id',
twentyCalendarEventId: 'calendar-event-id',
twentyRealMeetingKey: 'meeting-key',
},
metadata: RECALL_ROUTING_METADATA,
});
expect(result).toEqual({ ok: true, externalBotId: 'recall-bot-id' });
@@ -141,12 +125,7 @@ describe('recall bot api', () => {
const result = await scheduleRecallBot({
meetingUrl: 'https://meet.google.com/abc-defg-hij',
joinAt: '2026-01-01T13:00:00.000Z',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-id',
twentyCalendarEventId: 'calendar-event-id',
twentyRealMeetingKey: 'meeting-key',
},
metadata: RECALL_ROUTING_METADATA,
});
expect(result).toEqual({
@@ -168,12 +147,7 @@ describe('recall bot api', () => {
externalBotId: 'recall-bot-gone',
meetingUrl: 'https://meet.google.com/abc-defg-hij',
joinAt: '2026-01-01T13:00:00.000Z',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-id',
twentyCalendarEventId: 'calendar-event-id',
twentyRealMeetingKey: 'meeting-key',
},
metadata: RECALL_ROUTING_METADATA,
});
expect(result).toEqual({
@@ -204,12 +178,7 @@ describe('recall bot api', () => {
await scheduleRecallBot({
meetingUrl: 'https://meet.google.com/abc-defg-hij',
joinAt: '2026-01-01T13:00:00.000Z',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-id',
twentyCalendarEventId: 'calendar-event-id',
twentyRealMeetingKey: 'meeting-key',
},
metadata: RECALL_ROUTING_METADATA,
});
expect(fetchMock).toHaveBeenCalledWith(
@@ -522,26 +491,6 @@ describe('recall bot api', () => {
});
});
it('adds call recording metadata when convergence creates an async transcript', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 201,
json: async () => ({ id: 'recall-transcript-id' }),
});
const result = await createAsyncRecallTranscript({
externalRecordingId: 'recall-recording-id',
callRecordingId: 'call-recording-id',
});
expect(result).toEqual({ ok: true, transcriptId: 'recall-transcript-id' });
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({
provider: { recallai_async: { language_code: 'auto' } },
diarization: { use_separate_streams_when_available: true },
metadata: { twentyCallRecordingId: 'call-recording-id' },
});
});
it('does not retry async transcript creation failures', async () => {
fetchMock.mockResolvedValue({
ok: false,
@@ -10,10 +10,8 @@ type CreateAsyncRecallTranscriptResult =
export const createAsyncRecallTranscript = async ({
externalRecordingId,
callRecordingId,
}: {
externalRecordingId: string;
callRecordingId?: string;
}): Promise<CreateAsyncRecallTranscriptResult> => {
const configResult = getRecallApiConfig();
@@ -28,9 +26,6 @@ export const createAsyncRecallTranscript = async ({
body: {
provider: { recallai_async: { language_code: 'auto' } },
diarization: { use_separate_streams_when_available: true },
...(callRecordingId === undefined
? {}
: { metadata: { twentyCallRecordingId: callRecordingId } }),
},
maxAttempts: 1,
});
@@ -0,0 +1,8 @@
import { getRecallWebhookBotMetadata } from 'src/logic-functions/recall-api/get-recall-webhook-bot-metadata.util';
import { type RecallWebhookBody } from 'src/logic-functions/recall-api/parse-recall-webhook-event.util';
import { getString } from 'src/logic-functions/utils/get-string.util';
export const extractTwentyWorkspaceIdFromRecallWebhook = (
body: RecallWebhookBody,
): string | undefined =>
getString(getRecallWebhookBotMetadata(body)?.twentyWorkspaceId);
@@ -0,0 +1,18 @@
import { type RecallWebhookBody } from 'src/logic-functions/recall-api/parse-recall-webhook-event.util';
import { asRecord } from 'src/logic-functions/utils/as-record.util';
import { getRecordAtPath } from 'src/logic-functions/utils/get-record-at-path.util';
// Recall delivers bot metadata under several body shapes per event family; this is the single reader of all of them.
export const getRecallWebhookBotMetadata = (
body: RecallWebhookBody,
): Record<string, unknown> | undefined => {
const data = asRecord(body.data);
const bot = asRecord(body.bot);
return (
asRecord(bot?.metadata) ??
asRecord(getRecordAtPath(data, ['bot', 'metadata'])) ??
asRecord(getRecordAtPath(data, ['recording', 'metadata'])) ??
asRecord(data?.metadata)
);
};
@@ -1,5 +1,6 @@
import { isUndefined } from '@sniptt/guards';
import { getRecallWebhookBotMetadata } from 'src/logic-functions/recall-api/get-recall-webhook-bot-metadata.util';
import { asRecord } from 'src/logic-functions/utils/as-record.util';
import { getRecordAtPath } from 'src/logic-functions/utils/get-record-at-path.util';
import { getString } from 'src/logic-functions/utils/get-string.util';
@@ -60,10 +61,9 @@ export const parseRecallWebhookEvent = (
getString(getRecordAtPath(data, ['status', 'recording_id'])) ??
getString(getRecordAtPath(data, ['recording', 'id'])) ??
getString(data?.recording_id),
callRecordingIdFromMetadata: extractCallRecordingIdFromMetadata({
data,
bot,
}),
callRecordingIdFromMetadata: getString(
getRecallWebhookBotMetadata(body)?.twentyCallRecordingId,
),
recordingStartedAt: normalizeRecallTimestamp(
getString(getRecordAtPath(data, ['recording', 'started_at'])),
),
@@ -86,19 +86,3 @@ const getStatusCodeFromEventName = (event: string): string | undefined => {
return statusCode === 'status_change' ? undefined : statusCode;
};
const extractCallRecordingIdFromMetadata = ({
data,
bot,
}: {
data: Record<string, unknown> | undefined;
bot: Record<string, unknown> | undefined;
}): string | undefined => {
const metadata =
asRecord(bot?.metadata) ??
asRecord(getRecordAtPath(data, ['bot', 'metadata'])) ??
asRecord(getRecordAtPath(data, ['recording', 'metadata'])) ??
asRecord(data?.metadata);
return getString(metadata?.twentyCallRecordingId);
};
@@ -2,7 +2,7 @@ import { isUndefined } from '@sniptt/guards';
import { getRecallBotAutomaticLeave } from 'src/logic-functions/constants/recall-bot-automatic-leave';
import { getRecallBotRecordingConfig } from 'src/logic-functions/constants/recall-bot-recording-config';
import { type RecallBotMetadata } from 'src/logic-functions/types/recall-bot-metadata.type';
import { type RecallRoutingMetadata } from 'src/logic-functions/types/recall-routing-metadata.type';
import { type RecallBotScheduleResult } from 'src/logic-functions/types/recall-bot-operation-result.type';
import {
extractRecallBotId,
@@ -14,7 +14,7 @@ import { recallBotApiRequest } from 'src/logic-functions/recall-api/recall-bot-a
export type ScheduleRecallBotArgs = {
meetingUrl: string;
joinAt: string;
metadata: RecallBotMetadata;
metadata: RecallRoutingMetadata;
};
export const scheduleRecallBot = async ({
@@ -1,33 +1,31 @@
import { isNull, isUndefined } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { Response } from 'twenty-sdk/logic-function';
import { PROCESS_RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/process-recall-webhook-logic-function-universal-identifier';
import { RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/recall-webhook-logic-function-universal-identifier';
import { RECALL_WEBHOOK_SECRET_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-webhook-secret-env-var-name';
import { handleRecallWebhook } from 'src/logic-functions/flows/handle-recall-webhook.util';
import { extractTwentyWorkspaceIdFromRecallWebhook } from 'src/logic-functions/recall-api/extract-twenty-workspace-id-from-recall-webhook.util';
import { type RecallWebhookBody } from 'src/logic-functions/recall-api/parse-recall-webhook-event.util';
import { verifyRecallWebhookSignature } from 'src/logic-functions/recall-api/verify-recall-webhook-signature.util';
import { getApplicationVariableValue } from 'src/logic-functions/utils/get-application-variable-value.util';
import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
// Non-2xx makes Svix retry; a returned plain object would 200-ack permanently.
const rejectWebhook = (status: number, error: string): Response => {
console.error(`[call-recorder] webhook rejected: ${error}`);
return new Response({ error }, { status });
type RecallWebhookResolverResult = {
workspaceId: string;
targetLogicFunctionUniversalIdentifier: string;
payload: RecallWebhookBody;
};
export const recallWebhookRouteHandler = async (
// A thrown error becomes a non-2xx, which makes Svix retry; a returned result dispatches to the target.
export const recallWebhookRouteHandler = (
routePayload: RoutePayload<RecallWebhookBody>,
): Promise<object> => {
): RecallWebhookResolverResult => {
const webhookSecret = getApplicationVariableValue(
RECALL_WEBHOOK_SECRET_ENV_VAR_NAME,
);
if (!isNonEmptyString(webhookSecret)) {
return rejectWebhook(
500,
throw new Error(
'RECALL_WEBHOOK_SECRET server variable is not set. A server admin must copy it from the Recall webhook endpoint settings and set it on the Call Recorder application registration.',
);
}
@@ -35,8 +33,7 @@ export const recallWebhookRouteHandler = async (
const { rawBody } = routePayload;
if (isUndefined(rawBody)) {
return rejectWebhook(
500,
throw new Error(
'Raw request body was not forwarded by the server; cannot verify the webhook signature',
);
}
@@ -48,34 +45,39 @@ export const recallWebhookRouteHandler = async (
});
if (!signatureCheck.valid) {
return rejectWebhook(
401,
`Invalid webhook signature: ${signatureCheck.error}`,
throw new Error(`Invalid webhook signature: ${signatureCheck.error}`);
}
const body = routePayload.body;
if (isUndefined(body) || isNull(body)) {
throw new Error('Webhook payload was empty');
}
const workspaceId = extractTwentyWorkspaceIdFromRecallWebhook(body);
if (!isNonEmptyString(workspaceId)) {
throw new Error(
'Webhook payload is missing the Twenty workspace id in the Recall bot metadata',
);
}
if (isUndefined(routePayload.body) || isNull(routePayload.body)) {
return rejectWebhook(400, 'Webhook payload was empty');
}
return handleRecallWebhook({
client: new CoreApiClient(),
body: routePayload.body,
});
return {
workspaceId,
targetLogicFunctionUniversalIdentifier:
PROCESS_RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
payload: body,
};
};
export default defineLogicFunction({
universalIdentifier: RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'recall-webhook',
description:
'Receives Recall.ai webhook events and updates the matching CallRecording lifecycle status.',
'Verifies Recall.ai webhook signatures and resolves the target workspace for the matching CallRecording update.',
timeoutSeconds: 30,
handler: recallWebhookRouteHandler,
serverWebhookTriggerSettings: {
workspaceIdResolver: {
source: 'body',
path: 'data.bot.metadata.twentyWorkspaceId',
},
serverRouteTriggerSettings: {
forwardedRequestHeaders: [
'webhook-id',
'webhook-timestamp',
@@ -0,0 +1 @@
export type CallRecordingMediaFile = { fileId: string; label: string };
@@ -0,0 +1,20 @@
import { type CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status';
import { type CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
import { type CallRecordingMediaFile } from 'src/logic-functions/types/call-recording-media-file.type';
export type CallRecordingUpdateFields = Partial<{
// null clears a previously synced title when the calendar title disappears.
title: string | null;
status: CallRecordingStatus;
recordingRequestStatus: CallRecordingRequestStatus;
startedAt: string;
endedAt: string;
calendarEventId: string;
// null clears stale app-owned state on cancel/eject or reschedule.
externalBotId: string | null;
externalRecordingId: string;
callRecorderFailureReason: string | null;
transcript: Record<string, unknown>;
audio: CallRecordingMediaFile[];
video: CallRecordingMediaFile[];
}>;
@@ -1,6 +0,0 @@
export type RecallBotMetadata = {
twentyWorkspaceId: string;
twentyCallRecordingId: string;
twentyCalendarEventId: string;
twentyRealMeetingKey: string;
};
@@ -0,0 +1,4 @@
export type RecallRoutingMetadata = {
twentyWorkspaceId: string;
twentyCallRecordingId: string;
};
@@ -143,6 +143,15 @@ __metadata:
languageName: node
linkType: hard
"@emnapi/runtime@npm:^1.7.0":
version: 1.11.1
resolution: "@emnapi/runtime@npm:1.11.1"
dependencies:
tslib: "npm:^2.4.0"
checksum: 10c0/04332fb62076afc440aa23316c04bec42f584ca8b074e5507d08e2b33a47cbe0493b1aadb8f3c1057b64ae1e17f5bde1a7bc37f7facc9d0bc25c18197cbd366f
languageName: node
linkType: hard
"@emnapi/wasi-threads@npm:1.2.1":
version: 1.2.1
resolution: "@emnapi/wasi-threads@npm:1.2.1"
@@ -500,6 +509,233 @@ __metadata:
languageName: node
linkType: hard
"@img/colour@npm:^1.0.0":
version: 1.1.0
resolution: "@img/colour@npm:1.1.0"
checksum: 10c0/2ebea2c0bbaee73b99badcefa04e1e71d83f36e5369337d3121dca841f4569533c4e2faddda6d62dd247f0d5cca143711f9446c59bcce81e427ba433a7a94a17
languageName: node
linkType: hard
"@img/sharp-darwin-arm64@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-darwin-arm64@npm:0.34.5"
dependencies:
"@img/sharp-libvips-darwin-arm64": "npm:1.2.4"
dependenciesMeta:
"@img/sharp-libvips-darwin-arm64":
optional: true
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@img/sharp-darwin-x64@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-darwin-x64@npm:0.34.5"
dependencies:
"@img/sharp-libvips-darwin-x64": "npm:1.2.4"
dependenciesMeta:
"@img/sharp-libvips-darwin-x64":
optional: true
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@img/sharp-libvips-darwin-arm64@npm:1.2.4":
version: 1.2.4
resolution: "@img/sharp-libvips-darwin-arm64@npm:1.2.4"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@img/sharp-libvips-darwin-x64@npm:1.2.4":
version: 1.2.4
resolution: "@img/sharp-libvips-darwin-x64@npm:1.2.4"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@img/sharp-libvips-linux-arm64@npm:1.2.4":
version: 1.2.4
resolution: "@img/sharp-libvips-linux-arm64@npm:1.2.4"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@img/sharp-libvips-linux-arm@npm:1.2.4":
version: 1.2.4
resolution: "@img/sharp-libvips-linux-arm@npm:1.2.4"
conditions: os=linux & cpu=arm & libc=glibc
languageName: node
linkType: hard
"@img/sharp-libvips-linux-ppc64@npm:1.2.4":
version: 1.2.4
resolution: "@img/sharp-libvips-linux-ppc64@npm:1.2.4"
conditions: os=linux & cpu=ppc64 & libc=glibc
languageName: node
linkType: hard
"@img/sharp-libvips-linux-riscv64@npm:1.2.4":
version: 1.2.4
resolution: "@img/sharp-libvips-linux-riscv64@npm:1.2.4"
conditions: os=linux & cpu=riscv64 & libc=glibc
languageName: node
linkType: hard
"@img/sharp-libvips-linux-s390x@npm:1.2.4":
version: 1.2.4
resolution: "@img/sharp-libvips-linux-s390x@npm:1.2.4"
conditions: os=linux & cpu=s390x & libc=glibc
languageName: node
linkType: hard
"@img/sharp-libvips-linux-x64@npm:1.2.4":
version: 1.2.4
resolution: "@img/sharp-libvips-linux-x64@npm:1.2.4"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"@img/sharp-libvips-linuxmusl-arm64@npm:1.2.4":
version: 1.2.4
resolution: "@img/sharp-libvips-linuxmusl-arm64@npm:1.2.4"
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
"@img/sharp-libvips-linuxmusl-x64@npm:1.2.4":
version: 1.2.4
resolution: "@img/sharp-libvips-linuxmusl-x64@npm:1.2.4"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
"@img/sharp-linux-arm64@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-linux-arm64@npm:0.34.5"
dependencies:
"@img/sharp-libvips-linux-arm64": "npm:1.2.4"
dependenciesMeta:
"@img/sharp-libvips-linux-arm64":
optional: true
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@img/sharp-linux-arm@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-linux-arm@npm:0.34.5"
dependencies:
"@img/sharp-libvips-linux-arm": "npm:1.2.4"
dependenciesMeta:
"@img/sharp-libvips-linux-arm":
optional: true
conditions: os=linux & cpu=arm & libc=glibc
languageName: node
linkType: hard
"@img/sharp-linux-ppc64@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-linux-ppc64@npm:0.34.5"
dependencies:
"@img/sharp-libvips-linux-ppc64": "npm:1.2.4"
dependenciesMeta:
"@img/sharp-libvips-linux-ppc64":
optional: true
conditions: os=linux & cpu=ppc64 & libc=glibc
languageName: node
linkType: hard
"@img/sharp-linux-riscv64@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-linux-riscv64@npm:0.34.5"
dependencies:
"@img/sharp-libvips-linux-riscv64": "npm:1.2.4"
dependenciesMeta:
"@img/sharp-libvips-linux-riscv64":
optional: true
conditions: os=linux & cpu=riscv64 & libc=glibc
languageName: node
linkType: hard
"@img/sharp-linux-s390x@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-linux-s390x@npm:0.34.5"
dependencies:
"@img/sharp-libvips-linux-s390x": "npm:1.2.4"
dependenciesMeta:
"@img/sharp-libvips-linux-s390x":
optional: true
conditions: os=linux & cpu=s390x & libc=glibc
languageName: node
linkType: hard
"@img/sharp-linux-x64@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-linux-x64@npm:0.34.5"
dependencies:
"@img/sharp-libvips-linux-x64": "npm:1.2.4"
dependenciesMeta:
"@img/sharp-libvips-linux-x64":
optional: true
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"@img/sharp-linuxmusl-arm64@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-linuxmusl-arm64@npm:0.34.5"
dependencies:
"@img/sharp-libvips-linuxmusl-arm64": "npm:1.2.4"
dependenciesMeta:
"@img/sharp-libvips-linuxmusl-arm64":
optional: true
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
"@img/sharp-linuxmusl-x64@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-linuxmusl-x64@npm:0.34.5"
dependencies:
"@img/sharp-libvips-linuxmusl-x64": "npm:1.2.4"
dependenciesMeta:
"@img/sharp-libvips-linuxmusl-x64":
optional: true
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
"@img/sharp-wasm32@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-wasm32@npm:0.34.5"
dependencies:
"@emnapi/runtime": "npm:^1.7.0"
conditions: cpu=wasm32
languageName: node
linkType: hard
"@img/sharp-win32-arm64@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-win32-arm64@npm:0.34.5"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@img/sharp-win32-ia32@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-win32-ia32@npm:0.34.5"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"@img/sharp-win32-x64@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-win32-x64@npm:0.34.5"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"@inquirer/ansi@npm:^2.0.7":
version: 2.0.7
resolution: "@inquirer/ansi@npm:2.0.7"
@@ -1002,8 +1238,8 @@ __metadata:
oxlint: "npm:^0.16.0"
react: "npm:^19.0.0"
react-dom: "npm:^19.0.0"
twenty-client-sdk: "npm:2.15.0"
twenty-sdk: "npm:2.15.0"
twenty-client-sdk: "npm:2.16.0"
twenty-sdk: "npm:2.16.0"
typescript: "npm:^5.9.3"
vite-tsconfig-paths: "npm:^4.2.1"
vitest: "npm:^4.1.9"
@@ -1539,7 +1775,7 @@ __metadata:
languageName: node
linkType: hard
"detect-libc@npm:^2.0.3":
"detect-libc@npm:^2.0.3, detect-libc@npm:^2.1.2":
version: 2.1.2
resolution: "detect-libc@npm:2.1.2"
checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4
@@ -2775,6 +3011,99 @@ __metadata:
languageName: node
linkType: hard
"semver@npm:^7.7.3":
version: 7.8.5
resolution: "semver@npm:7.8.5"
bin:
semver: bin/semver.js
checksum: 10c0/b1f3127a5be8125a94f37188b361c212466c292c6910adce3ec106cff5dc211ccaedc4739c11bb70fda59d6fc1f040a9bca289f4e093451521a2372e5231fe0c
languageName: node
linkType: hard
"sharp@npm:^0.34.5":
version: 0.34.5
resolution: "sharp@npm:0.34.5"
dependencies:
"@img/colour": "npm:^1.0.0"
"@img/sharp-darwin-arm64": "npm:0.34.5"
"@img/sharp-darwin-x64": "npm:0.34.5"
"@img/sharp-libvips-darwin-arm64": "npm:1.2.4"
"@img/sharp-libvips-darwin-x64": "npm:1.2.4"
"@img/sharp-libvips-linux-arm": "npm:1.2.4"
"@img/sharp-libvips-linux-arm64": "npm:1.2.4"
"@img/sharp-libvips-linux-ppc64": "npm:1.2.4"
"@img/sharp-libvips-linux-riscv64": "npm:1.2.4"
"@img/sharp-libvips-linux-s390x": "npm:1.2.4"
"@img/sharp-libvips-linux-x64": "npm:1.2.4"
"@img/sharp-libvips-linuxmusl-arm64": "npm:1.2.4"
"@img/sharp-libvips-linuxmusl-x64": "npm:1.2.4"
"@img/sharp-linux-arm": "npm:0.34.5"
"@img/sharp-linux-arm64": "npm:0.34.5"
"@img/sharp-linux-ppc64": "npm:0.34.5"
"@img/sharp-linux-riscv64": "npm:0.34.5"
"@img/sharp-linux-s390x": "npm:0.34.5"
"@img/sharp-linux-x64": "npm:0.34.5"
"@img/sharp-linuxmusl-arm64": "npm:0.34.5"
"@img/sharp-linuxmusl-x64": "npm:0.34.5"
"@img/sharp-wasm32": "npm:0.34.5"
"@img/sharp-win32-arm64": "npm:0.34.5"
"@img/sharp-win32-ia32": "npm:0.34.5"
"@img/sharp-win32-x64": "npm:0.34.5"
detect-libc: "npm:^2.1.2"
semver: "npm:^7.7.3"
dependenciesMeta:
"@img/sharp-darwin-arm64":
optional: true
"@img/sharp-darwin-x64":
optional: true
"@img/sharp-libvips-darwin-arm64":
optional: true
"@img/sharp-libvips-darwin-x64":
optional: true
"@img/sharp-libvips-linux-arm":
optional: true
"@img/sharp-libvips-linux-arm64":
optional: true
"@img/sharp-libvips-linux-ppc64":
optional: true
"@img/sharp-libvips-linux-riscv64":
optional: true
"@img/sharp-libvips-linux-s390x":
optional: true
"@img/sharp-libvips-linux-x64":
optional: true
"@img/sharp-libvips-linuxmusl-arm64":
optional: true
"@img/sharp-libvips-linuxmusl-x64":
optional: true
"@img/sharp-linux-arm":
optional: true
"@img/sharp-linux-arm64":
optional: true
"@img/sharp-linux-ppc64":
optional: true
"@img/sharp-linux-riscv64":
optional: true
"@img/sharp-linux-s390x":
optional: true
"@img/sharp-linux-x64":
optional: true
"@img/sharp-linuxmusl-arm64":
optional: true
"@img/sharp-linuxmusl-x64":
optional: true
"@img/sharp-wasm32":
optional: true
"@img/sharp-win32-arm64":
optional: true
"@img/sharp-win32-ia32":
optional: true
"@img/sharp-win32-x64":
optional: true
checksum: 10c0/fd79e29df0597a7d5704b8461c51f944ead91a5243691697be6e8243b966402beda53ddc6f0a53b96ea3cb8221f0b244aa588114d3ebf8734fb4aefd41ab802f
languageName: node
linkType: hard
"siginfo@npm:^2.0.0":
version: 2.0.0
resolution: "siginfo@npm:2.0.0"
@@ -3002,22 +3331,22 @@ __metadata:
languageName: node
linkType: hard
"twenty-client-sdk@npm:2.15.0":
version: 2.15.0
resolution: "twenty-client-sdk@npm:2.15.0"
"twenty-client-sdk@npm:2.16.0":
version: 2.16.0
resolution: "twenty-client-sdk@npm:2.16.0"
dependencies:
"@genql/runtime": "npm:^2.10.0"
esbuild: "npm:^0.28.1"
graphql: "npm:^16.8.1"
lodash: "npm:^4.17.21"
prettier: "npm:^3.8.3"
checksum: 10c0/ea5143511ec3d42a0c2eaabda8833bac5c73c25fe04ba967d76b47b22c67468dd59d9e748a00e99a8085ffdb02d6cc5f41556f9e85a9785b2909b63adcb4205f
checksum: 10c0/f990220bec103a04ca50dc8bb38c295e94a4e51b27a3188f4a0ae515c204b76bf87f5591739eb705b97e7aa5a9aa6a215d0d4da05297e50a0392801ea64a2234
languageName: node
linkType: hard
"twenty-sdk@npm:2.15.0":
version: 2.15.0
resolution: "twenty-sdk@npm:2.15.0"
"twenty-sdk@npm:2.16.0":
version: 2.16.0
resolution: "twenty-sdk@npm:2.16.0"
dependencies:
"@sniptt/guards": "npm:^0.2.0"
axios: "npm:^1.16.0"
@@ -3034,13 +3363,14 @@ __metadata:
react: "npm:^19.2.0"
react-dom: "npm:^19.2.0"
semver: "npm:7.6.3"
sharp: "npm:^0.34.5"
tinyglobby: "npm:^0.2.15"
twenty-client-sdk: "npm:2.15.0"
twenty-client-sdk: "npm:2.16.0"
typescript: "npm:^5.9.3"
uuid: "npm:^13.0.2"
bin:
twenty: dist/cli.cjs
checksum: 10c0/0fe9a3653f3adaa54eb8398e520328ed2fd51b4c4284b4ed4105dfd92e3c866f2e9b5c6933f952e974684cd38169cdb26d64cfe06f058f4f4cb5bb1f6be7d2fc
checksum: 10c0/528f275f8bda022061a52c3563af514d2257f291fc5a232be90ca6011c50ff18a250984b6c68fc8e4c690644c92e1ccb0b89c511b8a19d832455c8860f2751a8
languageName: node
linkType: hard