[BREAKING CHANGE] harden call recording failure handling (#22062)
<!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22062?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -29,7 +29,7 @@ A workspace admin can adjust bot behavior through application variables:
|
||||
|
||||
### Configuring the webhook
|
||||
|
||||
The app exposes an unauthenticated route, `POST /webhook/recall`, that verifies the Recall/Svix signature and updates the matching `CallRecording`'s lifecycle status (`JOINING` → `RECORDING` → `PROCESSING`, or `FAILED_UNKNOWN`).
|
||||
The app exposes an unauthenticated route, `POST /webhook/recall`, that verifies the Recall/Svix signature and updates the matching `CallRecording`'s lifecycle status (`JOINING` → `RECORDING` → `PROCESSING`, or `FAILED`).
|
||||
|
||||
1. In the Recall.ai dashboard, create a webhook endpoint (Status Change Webhooks) pointing at the public URL of this app's `POST /webhook/recall` route.
|
||||
2. Copy the endpoint's signing secret — it starts with `whsec_`.
|
||||
|
||||
+39
-4
@@ -5,6 +5,10 @@ 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 () => {
|
||||
@@ -28,8 +32,9 @@ describe('App installation', () => {
|
||||
});
|
||||
|
||||
describe('CallRecording status contract', () => {
|
||||
it('accepts every status and request status value the app mirrors', async () => {
|
||||
it('accepts every app status supported by the current server and every request status value the app mirrors', async () => {
|
||||
const client = new CoreApiClient();
|
||||
const serverCallRecordingStatuses = await getServerCallRecordingStatuses();
|
||||
|
||||
const created = await client.mutation({
|
||||
createCallRecording: {
|
||||
@@ -52,13 +57,31 @@ describe('CallRecording status contract', () => {
|
||||
throw new Error('Expected call recording creation to return an id');
|
||||
}
|
||||
|
||||
for (const status of Object.values(CallRecordingStatus)) {
|
||||
const updated = await client.mutation({
|
||||
expect(serverCallRecordingStatuses).toEqual(
|
||||
expect.arrayContaining([
|
||||
CallRecordingStatus.SCHEDULED,
|
||||
CallRecordingStatus.JOINING,
|
||||
CallRecordingStatus.RECORDING,
|
||||
CallRecordingStatus.PROCESSING,
|
||||
CallRecordingStatus.COMPLETED,
|
||||
]),
|
||||
);
|
||||
|
||||
// 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 = {
|
||||
updateCallRecording: {
|
||||
__args: { id: callRecordingId, data: { status } },
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
} satisfies CurrentSchemaUpdateCallRecordingMutation;
|
||||
|
||||
const updated = await executeCurrentSchemaMutation(client, mutation);
|
||||
|
||||
expect(updated.updateCallRecording?.status).toBe(status);
|
||||
}
|
||||
@@ -86,3 +109,15 @@ describe('CallRecording status contract', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
type GeneratedCoreSchemaRuntime = {
|
||||
enumCallRecordingStatusEnum: Record<string, string>;
|
||||
};
|
||||
|
||||
const getServerCallRecordingStatuses = async (): Promise<string[]> => {
|
||||
const generatedCoreSchema = (await import(
|
||||
'twenty-client-sdk/core'
|
||||
)) as unknown as GeneratedCoreSchemaRuntime;
|
||||
|
||||
return Object.values(generatedCoreSchema.enumCallRecordingStatusEnum);
|
||||
};
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const MEETING_BOT_FAILURE_REASON_ON_CALL_RECORDING_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'33a577e4-02f5-48bd-8bed-d365949caa72';
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import { MEETING_BOT_FAILURE_REASON_ON_CALL_RECORDING_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/meeting-bot-failure-reason-on-call-recording-field-universal-identifier';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier:
|
||||
MEETING_BOT_FAILURE_REASON_ON_CALL_RECORDING_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.universalIdentifier,
|
||||
type: FieldType.TEXT,
|
||||
name: 'meetingBotFailureReason',
|
||||
label: 'Meeting Bot Failure Reason',
|
||||
description:
|
||||
'Provider-specific reason the meeting bot could not produce a recording.',
|
||||
icon: 'IconAlertTriangle',
|
||||
isNullable: true,
|
||||
isUIEditable: false,
|
||||
});
|
||||
+1
-1
@@ -5,5 +5,5 @@ export enum CallRecordingStatus {
|
||||
RECORDING = 'RECORDING',
|
||||
PROCESSING = 'PROCESSING',
|
||||
COMPLETED = 'COMPLETED',
|
||||
FAILED_UNKNOWN = 'FAILED_UNKNOWN',
|
||||
FAILED = 'FAILED',
|
||||
}
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
|
||||
|
||||
export const NON_TERMINAL_CALL_RECORDING_STATUSES = [
|
||||
CallRecordingStatus.SCHEDULED,
|
||||
CallRecordingStatus.JOINING,
|
||||
CallRecordingStatus.RECORDING,
|
||||
CallRecordingStatus.PROCESSING,
|
||||
] satisfies CallRecordingStatus[];
|
||||
+2
-2
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import { completeCallRecordingIngestion } from 'src/logic-functions/data/complete-call-recording-ingestion.util';
|
||||
|
||||
describe('completeCallRecordingIngestion', () => {
|
||||
it('guards the flip with status != COMPLETED and returns true when the row is claimed', async () => {
|
||||
it('guards the flip with non-terminal statuses and returns true when the row is claimed', async () => {
|
||||
let capturedArgs: { filter: unknown; data: unknown } | undefined;
|
||||
const mutation = vi.fn(async (mutationArg: any) => {
|
||||
capturedArgs = mutationArg.updateCallRecordings.__args;
|
||||
@@ -22,7 +22,7 @@ describe('completeCallRecordingIngestion', () => {
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(capturedArgs?.filter).toEqual({
|
||||
id: { eq: 'call-recording-1' },
|
||||
status: { neq: 'COMPLETED' },
|
||||
status: { in: ['SCHEDULED', 'JOINING', 'RECORDING', 'PROCESSING'] },
|
||||
});
|
||||
expect(capturedArgs?.data).toEqual({ status: 'COMPLETED' });
|
||||
});
|
||||
|
||||
+10
-3
@@ -1,23 +1,30 @@
|
||||
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 result = await client.mutation({
|
||||
const mutation = {
|
||||
updateCallRecordings: {
|
||||
__args: {
|
||||
filter: {
|
||||
id: { eq: id },
|
||||
status: { neq: CallRecordingStatus.COMPLETED },
|
||||
status: { in: NON_TERMINAL_CALL_RECORDING_STATUSES },
|
||||
},
|
||||
data: { status: CallRecordingStatus.COMPLETED },
|
||||
},
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
} satisfies CurrentSchemaUpdateCallRecordingsMutation;
|
||||
|
||||
const result = await executeCurrentSchemaMutation(client, mutation);
|
||||
|
||||
return (result.updateCallRecordings ?? []).length > 0;
|
||||
};
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
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 meetingBotFailureReason.
|
||||
export const executeCurrentSchemaMutation = (
|
||||
client: CoreApiClient,
|
||||
mutation: CurrentSchemaMutation,
|
||||
): Promise<CurrentSchemaMutationResult> => {
|
||||
const currentSchemaClient = client as {
|
||||
mutation: CurrentSchemaMutationFunction;
|
||||
};
|
||||
|
||||
return currentSchemaClient.mutation(mutation);
|
||||
};
|
||||
+5
@@ -20,6 +20,7 @@ type CallRecordingNode = {
|
||||
calendarEventId?: string | null;
|
||||
externalBotId?: string | null;
|
||||
externalRecordingId?: string | null;
|
||||
meetingBotFailureReason?: string | null;
|
||||
};
|
||||
|
||||
export const findCallRecordingsByFilter = async (
|
||||
@@ -50,6 +51,7 @@ export const findCallRecordingsByFilter = async (
|
||||
calendarEventId: true,
|
||||
externalBotId: true,
|
||||
externalRecordingId: true,
|
||||
meetingBotFailureReason: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -75,6 +77,9 @@ export const findCallRecordingsByFilter = async (
|
||||
externalRecordingId: normalizeOptionalString(
|
||||
callRecording.externalRecordingId,
|
||||
),
|
||||
meetingBotFailureReason: normalizeOptionalString(
|
||||
callRecording.meetingBotFailureReason,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
+10
-3
@@ -2,6 +2,10 @@ 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.
|
||||
@@ -11,9 +15,10 @@ export type CallRecordingUpdateFields = Partial<{
|
||||
startedAt: string;
|
||||
endedAt: string;
|
||||
calendarEventId: string;
|
||||
// null clears the field on cancel/eject; the only field we ever write null to.
|
||||
// null clears stale app-owned state on cancel/eject or reschedule.
|
||||
externalBotId: string | null;
|
||||
externalRecordingId: string;
|
||||
meetingBotFailureReason: string | null;
|
||||
transcript: Record<string, unknown>;
|
||||
audio: { fileId: string; label: string }[];
|
||||
video: { fileId: string; label: string }[];
|
||||
@@ -29,7 +34,7 @@ export const updateCallRecording = async (
|
||||
data: CallRecordingUpdateFields;
|
||||
},
|
||||
): Promise<void> => {
|
||||
await client.mutation({
|
||||
const mutation = {
|
||||
updateCallRecording: {
|
||||
__args: {
|
||||
id,
|
||||
@@ -37,5 +42,7 @@ export const updateCallRecording = async (
|
||||
},
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
} satisfies CurrentSchemaUpdateCallRecordingMutation;
|
||||
|
||||
await executeCurrentSchemaMutation(client, mutation);
|
||||
};
|
||||
|
||||
+2
-2
@@ -7,12 +7,12 @@ describe('isCallRecordingStatusDowngrade', () => {
|
||||
['SCHEDULED', 'JOINING', false],
|
||||
['JOINING', 'RECORDING', false],
|
||||
['RECORDING', 'PROCESSING', false],
|
||||
['PROCESSING', 'FAILED_UNKNOWN', false],
|
||||
['PROCESSING', 'FAILED', false],
|
||||
['PROCESSING', 'COMPLETED', false],
|
||||
['RECORDING', 'RECORDING', false],
|
||||
['COMPLETED', 'RECORDING', true],
|
||||
['PROCESSING', 'JOINING', true],
|
||||
['FAILED_UNKNOWN', 'RECORDING', true],
|
||||
['FAILED', 'RECORDING', true],
|
||||
['JOINING', 'SCHEDULED', true],
|
||||
])('from %s to %s -> %s', (fromStatus, toStatus, expected) => {
|
||||
expect(isCallRecordingStatusDowngrade({ fromStatus, toStatus })).toBe(
|
||||
|
||||
+34
@@ -65,4 +65,38 @@ describe('shouldCompleteCallRecordingIngestion', () => {
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not complete a persisted failed recording', () => {
|
||||
expect(
|
||||
shouldCompleteCallRecordingIngestion({
|
||||
current: {
|
||||
status: CallRecordingStatus.FAILED,
|
||||
startedAt: '2026-06-10T09:00:00.000Z',
|
||||
endedAt: '2026-06-10T10:00:00.000Z',
|
||||
transcript: filledTranscript,
|
||||
audio: filledAudio,
|
||||
video: filledVideo,
|
||||
},
|
||||
updateData: {},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not complete when the incoming update marks the recording as failed', () => {
|
||||
expect(
|
||||
shouldCompleteCallRecordingIngestion({
|
||||
current: {
|
||||
status: CallRecordingStatus.PROCESSING,
|
||||
startedAt: '2026-06-10T09:00:00.000Z',
|
||||
endedAt: '2026-06-10T10:00:00.000Z',
|
||||
transcript: filledTranscript,
|
||||
audio: filledAudio,
|
||||
video: filledVideo,
|
||||
},
|
||||
updateData: {
|
||||
status: CallRecordingStatus.FAILED,
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { isNull } from '@sniptt/guards';
|
||||
|
||||
export const buildTranscriptFailureReason = (subCode: string | null): string => {
|
||||
return isNull(subCode)
|
||||
? 'transcript_failed'
|
||||
: `transcript_failed:${subCode}`;
|
||||
};
|
||||
+1
-1
@@ -8,7 +8,7 @@ const CALL_RECORDING_STATUS_PROGRESSION: Record<CallRecordingStatus, number> = {
|
||||
[CallRecordingStatus.JOINING]: 1,
|
||||
[CallRecordingStatus.RECORDING]: 2,
|
||||
[CallRecordingStatus.PROCESSING]: 3,
|
||||
[CallRecordingStatus.FAILED_UNKNOWN]: 4,
|
||||
[CallRecordingStatus.FAILED]: 4,
|
||||
[CallRecordingStatus.COMPLETED]: 5,
|
||||
};
|
||||
|
||||
|
||||
+7
-1
@@ -4,4 +4,10 @@ export const isRecallRecordingDoneSignal = ({
|
||||
}: {
|
||||
event: string;
|
||||
statusCode: string | undefined;
|
||||
}): boolean => event === 'recording.done' || statusCode === 'done';
|
||||
}): boolean => {
|
||||
return (
|
||||
event === 'recording.done' ||
|
||||
event === 'recording.failed' ||
|
||||
statusCode === 'done'
|
||||
);
|
||||
};
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ export const mapRecallStatusCodeToCallRecordingStatus = (
|
||||
case 'fatal':
|
||||
case 'analysis_failed':
|
||||
case 'recording_permission_denied':
|
||||
return CallRecordingStatus.FAILED_UNKNOWN;
|
||||
return CallRecordingStatus.FAILED;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
|
||||
+2
@@ -19,6 +19,8 @@ export const shouldCompleteCallRecordingIngestion = ({
|
||||
updateData: CallRecordingUpdateFields;
|
||||
}): boolean =>
|
||||
current.status !== CallRecordingStatus.COMPLETED &&
|
||||
current.status !== CallRecordingStatus.FAILED &&
|
||||
updateData.status !== CallRecordingStatus.FAILED &&
|
||||
computeCallRecordingCharge({
|
||||
startedAt: updateData.startedAt ?? current.startedAt,
|
||||
endedAt: updateData.endedAt ?? current.endedAt,
|
||||
|
||||
+38
-3
@@ -184,6 +184,37 @@ describe('convergeDivergedCallRecordings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('marks FAILED when Recall is done but has no recording artifact path', async () => {
|
||||
getRecallBotMock.mockResolvedValue({
|
||||
ok: true,
|
||||
bot: {
|
||||
status_changes: [
|
||||
{ code: 'done', created_at: '2026-06-09T14:05:00.000Z' },
|
||||
],
|
||||
recordings: [],
|
||||
},
|
||||
});
|
||||
const client = buildClient([buildStuckRecordingNode()]);
|
||||
|
||||
const result = await convergeDivergedCallRecordings({
|
||||
client: client as unknown as CoreApiClient,
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
expect(listRecallTranscriptsMock).not.toHaveBeenCalled();
|
||||
expect(ingestCallRecordingMediaMock).not.toHaveBeenCalled();
|
||||
expect(client.mutations).toEqual([
|
||||
{
|
||||
id: 'call-recording-1',
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
meetingBotFailureReason: 'recording_artifacts_unavailable',
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(result.updatedCallRecordingIds).toEqual(['call-recording-1']);
|
||||
});
|
||||
|
||||
it('completes and charges when convergence lands the last artifact', async () => {
|
||||
getRecallBotMock.mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -308,7 +339,7 @@ describe('convergeDivergedCallRecordings', () => {
|
||||
expect(result.skippedNotStartedCallRecordingIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('marks FAILED_UNKNOWN without clearing the bot id when Recall returns 404', async () => {
|
||||
it('marks FAILED without clearing the bot id when Recall returns 404', async () => {
|
||||
getRecallBotMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
@@ -324,7 +355,10 @@ describe('convergeDivergedCallRecordings', () => {
|
||||
expect(client.mutations).toEqual([
|
||||
{
|
||||
id: 'call-recording-1',
|
||||
data: { status: 'FAILED_UNKNOWN' },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
meetingBotFailureReason: 'recall_bot_not_found',
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(result.markedFailedCallRecordingIds).toEqual(['call-recording-1']);
|
||||
@@ -658,12 +692,13 @@ describe('convergeDivergedCallRecordings', () => {
|
||||
{
|
||||
id: 'call-recording-1',
|
||||
data: {
|
||||
status: 'FAILED_UNKNOWN',
|
||||
status: 'FAILED',
|
||||
transcript: {
|
||||
recallTranscriptId: 'recall-transcript-1',
|
||||
status: 'FAILED',
|
||||
subCode: 'audio_missing',
|
||||
},
|
||||
meetingBotFailureReason: 'transcript_failed:audio_missing',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
+52
-1
@@ -970,6 +970,56 @@ describe('handleRecallWebhook', () => {
|
||||
expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks FAILED on recording.done when no recording artifact path exists', async () => {
|
||||
getRecallBotMock.mockResolvedValue({
|
||||
ok: true,
|
||||
bot: { id: 'recall-bot-1', recordings: [] },
|
||||
});
|
||||
const client = new FakeCoreApiClient([
|
||||
{
|
||||
id: 'call-recording-1',
|
||||
status: 'PROCESSING',
|
||||
externalBotId: 'recall-bot-1',
|
||||
startedAt: '2026-01-01T13:02:00.000Z',
|
||||
endedAt: '2026-01-01T14:05:00.000Z',
|
||||
transcript: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await handleRecallWebhook({
|
||||
client: client as unknown as CoreApiClient,
|
||||
body: {
|
||||
event: 'recording.done',
|
||||
data: {
|
||||
bot: {
|
||||
id: 'recall-bot-1',
|
||||
metadata: {
|
||||
twentyWorkspaceId: WORKSPACE_ID,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'updated',
|
||||
event: 'recording.done',
|
||||
callRecordingId: 'call-recording-1',
|
||||
callRecordingStatus: 'FAILED',
|
||||
});
|
||||
expect(client.mutations).toEqual([
|
||||
{
|
||||
id: 'call-recording-1',
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
externalBotId: 'recall-bot-1',
|
||||
meetingBotFailureReason: 'recording_artifacts_unavailable',
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('completes and charges on transcript.done when media is already ingested', async () => {
|
||||
const transcriptContent = [
|
||||
{
|
||||
@@ -1187,7 +1237,8 @@ describe('handleRecallWebhook', () => {
|
||||
status: 'FAILED',
|
||||
subCode: 'transcription_failed',
|
||||
},
|
||||
status: 'FAILED_UNKNOWN',
|
||||
meetingBotFailureReason: 'transcript_failed:transcription_failed',
|
||||
status: 'FAILED',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
+81
-9
@@ -3,6 +3,7 @@ import { type CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status';
|
||||
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 { TWENTY_PAGE_SIZE } from 'src/logic-functions/constants/twenty-page-size';
|
||||
import { type FilesFieldValue } from 'src/logic-functions/types/files-field-value.type';
|
||||
import {
|
||||
@@ -17,6 +18,7 @@ import { getRecallBot } from 'src/logic-functions/recall-api/get-recall-bot.util
|
||||
import { ingestCallRecordingMedia } from 'src/logic-functions/flows/ingest-call-recording-media.util';
|
||||
import { isCallRecordingStatusDowngrade } from 'src/logic-functions/domain/is-call-recording-status-downgrade.util';
|
||||
import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
|
||||
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 { type ConvergeDivergedCallRecordingsResult } from 'src/logic-functions/flows/converge-diverged-call-recordings-result.type';
|
||||
@@ -28,13 +30,6 @@ import {
|
||||
|
||||
const CONVERGENCE_LOOKBACK_DAYS = 7;
|
||||
|
||||
const NON_TERMINAL_CALL_RECORDING_STATUSES = [
|
||||
CallRecordingStatus.SCHEDULED,
|
||||
CallRecordingStatus.JOINING,
|
||||
CallRecordingStatus.RECORDING,
|
||||
CallRecordingStatus.PROCESSING,
|
||||
];
|
||||
|
||||
type DivergedCallRecordingCandidate = {
|
||||
id: string;
|
||||
status: string | undefined;
|
||||
@@ -285,6 +280,18 @@ const convergeCallRecording = async ({
|
||||
);
|
||||
}
|
||||
|
||||
const terminalArtifactGateFailureUpdate =
|
||||
buildTerminalArtifactGateFailureUpdate({
|
||||
candidate,
|
||||
convergence,
|
||||
externalRecordingId,
|
||||
updateData,
|
||||
});
|
||||
|
||||
if (!isUndefined(terminalArtifactGateFailureUpdate)) {
|
||||
Object.assign(updateData, terminalArtifactGateFailureUpdate);
|
||||
}
|
||||
|
||||
const completesIngestion = shouldCompleteCallRecordingIngestion({
|
||||
current: candidate,
|
||||
updateData,
|
||||
@@ -322,6 +329,11 @@ const buildConvergenceFieldUpdates = ({
|
||||
})
|
||||
) {
|
||||
updateData.status = convergence.status;
|
||||
|
||||
if (convergence.status === CallRecordingStatus.FAILED) {
|
||||
updateData.meetingBotFailureReason =
|
||||
convergence.failureReason ?? 'recall_bot_failed';
|
||||
}
|
||||
}
|
||||
|
||||
if (isUndefined(candidate.startedAt) && !isUndefined(convergence.startedAt)) {
|
||||
@@ -342,6 +354,63 @@ const buildConvergenceFieldUpdates = ({
|
||||
return updateData;
|
||||
};
|
||||
|
||||
type TerminalArtifactGateFailureUpdate = {
|
||||
status: CallRecordingStatus.FAILED;
|
||||
meetingBotFailureReason: string;
|
||||
};
|
||||
|
||||
const buildTerminalArtifactGateFailureUpdate = ({
|
||||
candidate,
|
||||
convergence,
|
||||
externalRecordingId,
|
||||
updateData,
|
||||
}: {
|
||||
candidate: DivergedCallRecordingCandidate;
|
||||
convergence: RecallBotConvergence;
|
||||
externalRecordingId: string | undefined;
|
||||
updateData: CallRecordingUpdateFields;
|
||||
}): TerminalArtifactGateFailureUpdate | undefined => {
|
||||
if (
|
||||
candidate.status === CallRecordingStatus.COMPLETED ||
|
||||
updateData.status === CallRecordingStatus.FAILED ||
|
||||
!convergence.isRecallRecordingDone ||
|
||||
!isUndefined(externalRecordingId) ||
|
||||
hasRecordingArtifactPath({ candidate, updateData })
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
status: CallRecordingStatus.FAILED,
|
||||
meetingBotFailureReason:
|
||||
convergence.failureReason ?? 'recording_artifacts_unavailable',
|
||||
};
|
||||
};
|
||||
|
||||
const hasRecordingArtifactPath = ({
|
||||
candidate,
|
||||
updateData,
|
||||
}: {
|
||||
candidate: DivergedCallRecordingCandidate;
|
||||
updateData: CallRecordingUpdateFields;
|
||||
}): boolean => {
|
||||
return (
|
||||
isNonEmptyArray(updateData.audio ?? candidate.audio) ||
|
||||
isNonEmptyArray(updateData.video ?? candidate.video) ||
|
||||
hasReachableTranscript(updateData.transcript ?? candidate.transcript)
|
||||
);
|
||||
};
|
||||
|
||||
const hasReachableTranscript = (transcript: unknown): boolean => {
|
||||
if (isUndefined(transcript)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const marker = parseTranscriptMarker(transcript);
|
||||
|
||||
return isUndefined(marker) || marker.status === 'PENDING';
|
||||
};
|
||||
|
||||
const markCallRecordingFailedAfterBotLoss = async ({
|
||||
client,
|
||||
candidate,
|
||||
@@ -361,7 +430,7 @@ const markCallRecordingFailedAfterBotLoss = async ({
|
||||
if (
|
||||
isCallRecordingStatusDowngrade({
|
||||
fromStatus: candidate.status,
|
||||
toStatus: CallRecordingStatus.FAILED_UNKNOWN,
|
||||
toStatus: CallRecordingStatus.FAILED,
|
||||
})
|
||||
) {
|
||||
result.unconvergeableCallRecordingIds.push(candidate.id);
|
||||
@@ -371,7 +440,10 @@ const markCallRecordingFailedAfterBotLoss = async ({
|
||||
|
||||
await updateCallRecording(client, {
|
||||
id: candidate.id,
|
||||
data: { status: CallRecordingStatus.FAILED_UNKNOWN },
|
||||
data: {
|
||||
status: CallRecordingStatus.FAILED,
|
||||
meetingBotFailureReason: 'recall_bot_not_found',
|
||||
},
|
||||
});
|
||||
result.markedFailedCallRecordingIds.push(candidate.id);
|
||||
};
|
||||
|
||||
+160
-29
@@ -4,6 +4,7 @@ import { type CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
|
||||
import { type FilesFieldValue } from 'src/logic-functions/types/files-field-value.type';
|
||||
import { buildFailedTranscriptMarker } from 'src/logic-functions/domain/build-failed-transcript-marker.util';
|
||||
import { buildTranscriptFailureReason } from 'src/logic-functions/domain/build-transcript-failure-reason.util';
|
||||
import { downloadTranscript } from 'src/logic-functions/flows/download-transcript.util';
|
||||
import { extractRecallBotConvergence } from 'src/logic-functions/recall-api/extract-recall-bot-convergence.util';
|
||||
import { getRecallBot } from 'src/logic-functions/recall-api/get-recall-bot.util';
|
||||
@@ -36,6 +37,11 @@ type MatchedCallRecording = {
|
||||
video?: FilesFieldValue;
|
||||
};
|
||||
|
||||
type ExternalRecordingIdResolution = {
|
||||
externalRecordingId: string | undefined;
|
||||
providerLookupFailed: boolean;
|
||||
};
|
||||
|
||||
type RecallWebhookHandlerResult =
|
||||
| {
|
||||
status: 'updated';
|
||||
@@ -129,27 +135,51 @@ const handleRecallStatusEvent = async ({
|
||||
}
|
||||
|
||||
const updateData: CallRecordingUpdateFields = {
|
||||
status: callRecordingStatus,
|
||||
...(isUndefined(webhookEvent.externalBotId)
|
||||
? {}
|
||||
: { externalBotId: webhookEvent.externalBotId }),
|
||||
...buildExternalRecordingIdUpdate(webhookEvent),
|
||||
...buildCallRecordingStatusUpdate({
|
||||
reason: getRecallWebhookFailureReason(webhookEvent),
|
||||
status: callRecordingStatus,
|
||||
}),
|
||||
...buildRecordingTimestampsUpdate({ webhookEvent, callRecording }),
|
||||
};
|
||||
|
||||
if (isRecallRecordingDoneSignal({ event, statusCode })) {
|
||||
const externalRecordingIdResolution = await resolveExternalRecordingId({
|
||||
callRecording,
|
||||
webhookEvent,
|
||||
});
|
||||
|
||||
Object.assign(
|
||||
updateData,
|
||||
await buildTranscriptArtifactUpdate({
|
||||
callRecording,
|
||||
webhookEvent,
|
||||
externalRecordingId: externalRecordingIdResolution.externalRecordingId,
|
||||
}),
|
||||
);
|
||||
|
||||
Object.assign(
|
||||
updateData,
|
||||
await buildMediaIngestionUpdate({ callRecording, webhookEvent }),
|
||||
await buildMediaIngestionUpdate({
|
||||
callRecording,
|
||||
externalRecordingId: externalRecordingIdResolution.externalRecordingId,
|
||||
}),
|
||||
);
|
||||
|
||||
const terminalArtifactGateFailureUpdate =
|
||||
buildTerminalArtifactGateFailureUpdate({
|
||||
callRecording,
|
||||
providerLookupFailed:
|
||||
externalRecordingIdResolution.providerLookupFailed,
|
||||
updateData,
|
||||
webhookEvent,
|
||||
});
|
||||
|
||||
if (!isUndefined(terminalArtifactGateFailureUpdate)) {
|
||||
Object.assign(updateData, terminalArtifactGateFailureUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
const { completesIngestion } = await persistCallRecordingProgress(client, {
|
||||
@@ -245,7 +275,7 @@ const mapRecallEventToCallRecordingStatus = ({
|
||||
}
|
||||
|
||||
if (event === 'recording.failed') {
|
||||
return CallRecordingStatus.FAILED_UNKNOWN;
|
||||
return CallRecordingStatus.FAILED;
|
||||
}
|
||||
|
||||
return mapRecallStatusCodeToCallRecordingStatus(statusCode);
|
||||
@@ -290,15 +320,113 @@ const buildExternalRecordingIdUpdate = (
|
||||
? {}
|
||||
: { externalRecordingId: webhookEvent.externalRecordingId };
|
||||
|
||||
type NonFailedCallRecordingStatus = Exclude<
|
||||
CallRecordingStatus,
|
||||
CallRecordingStatus.FAILED
|
||||
>;
|
||||
|
||||
type CallRecordingStatusUpdate =
|
||||
| {
|
||||
status: NonFailedCallRecordingStatus;
|
||||
}
|
||||
| {
|
||||
status: CallRecordingStatus.FAILED;
|
||||
meetingBotFailureReason: string;
|
||||
};
|
||||
|
||||
type TerminalArtifactGateFailureUpdate = {
|
||||
status: CallRecordingStatus.FAILED;
|
||||
meetingBotFailureReason: string;
|
||||
};
|
||||
|
||||
const buildCallRecordingStatusUpdate = ({
|
||||
reason,
|
||||
status,
|
||||
}: {
|
||||
reason: string;
|
||||
status: CallRecordingStatus;
|
||||
}): CallRecordingStatusUpdate => {
|
||||
if (status === CallRecordingStatus.FAILED) {
|
||||
return { status, meetingBotFailureReason: reason };
|
||||
}
|
||||
|
||||
return { status };
|
||||
};
|
||||
|
||||
const buildTerminalArtifactGateFailureUpdate = ({
|
||||
callRecording,
|
||||
providerLookupFailed,
|
||||
updateData,
|
||||
webhookEvent,
|
||||
}: {
|
||||
callRecording: MatchedCallRecording;
|
||||
providerLookupFailed: boolean;
|
||||
updateData: CallRecordingUpdateFields;
|
||||
webhookEvent: RecallWebhookEvent;
|
||||
}): TerminalArtifactGateFailureUpdate | undefined => {
|
||||
if (updateData.status === CallRecordingStatus.FAILED) {
|
||||
return isUndefined(updateData.meetingBotFailureReason)
|
||||
? {
|
||||
status: CallRecordingStatus.FAILED,
|
||||
meetingBotFailureReason: getRecallWebhookFailureReason(webhookEvent),
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
providerLookupFailed ||
|
||||
hasRecordingArtifactPath({ callRecording, updateData })
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
status: CallRecordingStatus.FAILED,
|
||||
meetingBotFailureReason: 'recording_artifacts_unavailable',
|
||||
};
|
||||
};
|
||||
|
||||
const getRecallWebhookFailureReason = ({
|
||||
event,
|
||||
statusCode,
|
||||
}: RecallWebhookEvent): string => statusCode ?? event;
|
||||
|
||||
const hasRecordingArtifactPath = ({
|
||||
callRecording,
|
||||
updateData,
|
||||
}: {
|
||||
callRecording: MatchedCallRecording;
|
||||
updateData: CallRecordingUpdateFields;
|
||||
}): boolean => {
|
||||
return (
|
||||
!isUndefined(
|
||||
updateData.externalRecordingId ?? callRecording.externalRecordingId,
|
||||
) ||
|
||||
isNonEmptyArray(updateData.audio ?? callRecording.audio) ||
|
||||
isNonEmptyArray(updateData.video ?? callRecording.video) ||
|
||||
hasReachableTranscript(updateData.transcript ?? callRecording.transcript)
|
||||
);
|
||||
};
|
||||
|
||||
const hasReachableTranscript = (transcript: unknown): boolean => {
|
||||
if (isNull(transcript) || isUndefined(transcript)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const marker = parseTranscriptMarker(transcript);
|
||||
|
||||
return isUndefined(marker) || marker.status === 'PENDING';
|
||||
};
|
||||
|
||||
const isTranscriptUnset = (callRecording: MatchedCallRecording): boolean =>
|
||||
isUndefined(callRecording.transcript);
|
||||
|
||||
const buildMediaIngestionUpdate = async ({
|
||||
callRecording,
|
||||
webhookEvent,
|
||||
externalRecordingId,
|
||||
}: {
|
||||
callRecording: MatchedCallRecording;
|
||||
webhookEvent: RecallWebhookEvent;
|
||||
externalRecordingId: string | undefined;
|
||||
}): Promise<Pick<CallRecordingUpdateFields, 'audio' | 'video'>> => {
|
||||
const hasAudio = isNonEmptyArray(callRecording.audio);
|
||||
const hasVideo = isNonEmptyArray(callRecording.video);
|
||||
@@ -307,11 +435,6 @@ const buildMediaIngestionUpdate = async ({
|
||||
return {};
|
||||
}
|
||||
|
||||
const externalRecordingId = await resolveExternalRecordingId({
|
||||
callRecording,
|
||||
webhookEvent,
|
||||
});
|
||||
|
||||
if (isUndefined(externalRecordingId)) {
|
||||
console.warn(
|
||||
`[twenty-meeting-bot] cannot ingest media for call recording ${callRecording.id}: no Recall recording id available`,
|
||||
@@ -330,16 +453,11 @@ const buildMediaIngestionUpdate = async ({
|
||||
|
||||
const buildTranscriptArtifactUpdate = async ({
|
||||
callRecording,
|
||||
webhookEvent,
|
||||
externalRecordingId,
|
||||
}: {
|
||||
callRecording: MatchedCallRecording;
|
||||
webhookEvent: RecallWebhookEvent;
|
||||
externalRecordingId: string | undefined;
|
||||
}): Promise<CallRecordingUpdateFields> => {
|
||||
const externalRecordingId = await resolveExternalRecordingId({
|
||||
callRecording,
|
||||
webhookEvent,
|
||||
});
|
||||
|
||||
if (isUndefined(externalRecordingId)) {
|
||||
console.warn(
|
||||
`[twenty-meeting-bot] cannot reconcile transcript for call recording ${callRecording.id}: no Recall recording id available`,
|
||||
@@ -371,16 +489,24 @@ const resolveExternalRecordingId = async ({
|
||||
}: {
|
||||
callRecording: MatchedCallRecording;
|
||||
webhookEvent: RecallWebhookEvent;
|
||||
}): Promise<string | undefined> =>
|
||||
webhookEvent.externalRecordingId ??
|
||||
callRecording.externalRecordingId ??
|
||||
(isUndefined(webhookEvent.externalBotId)
|
||||
? undefined
|
||||
: await fetchExternalRecordingIdFromRecallBot(webhookEvent.externalBotId));
|
||||
}): Promise<ExternalRecordingIdResolution> => {
|
||||
const externalRecordingId =
|
||||
webhookEvent.externalRecordingId ?? callRecording.externalRecordingId;
|
||||
|
||||
if (!isUndefined(externalRecordingId)) {
|
||||
return { externalRecordingId, providerLookupFailed: false };
|
||||
}
|
||||
|
||||
if (isUndefined(webhookEvent.externalBotId)) {
|
||||
return { externalRecordingId: undefined, providerLookupFailed: false };
|
||||
}
|
||||
|
||||
return fetchExternalRecordingIdFromRecallBot(webhookEvent.externalBotId);
|
||||
};
|
||||
|
||||
const fetchExternalRecordingIdFromRecallBot = async (
|
||||
externalBotId: string,
|
||||
): Promise<string | undefined> => {
|
||||
): Promise<ExternalRecordingIdResolution> => {
|
||||
const botResult = await getRecallBot({ externalBotId });
|
||||
|
||||
if (!botResult.ok) {
|
||||
@@ -388,10 +514,14 @@ const fetchExternalRecordingIdFromRecallBot = async (
|
||||
`[twenty-meeting-bot] failed to fetch Recall bot ${externalBotId} while resolving a recording id: ${botResult.errorMessage}`,
|
||||
);
|
||||
|
||||
return undefined;
|
||||
return { externalRecordingId: undefined, providerLookupFailed: true };
|
||||
}
|
||||
|
||||
return extractRecallBotConvergence(botResult.bot).externalRecordingId;
|
||||
return {
|
||||
externalRecordingId: extractRecallBotConvergence(botResult.bot)
|
||||
.externalRecordingId,
|
||||
providerLookupFailed: false,
|
||||
};
|
||||
};
|
||||
|
||||
const handleRecallTranscriptEvent = async ({
|
||||
@@ -524,12 +654,13 @@ const applyTranscriptFailure = async ({
|
||||
transcriptId ?? existingMarker?.recallTranscriptId ?? null,
|
||||
subCode,
|
||||
}),
|
||||
meetingBotFailureReason: buildTranscriptFailureReason(subCode),
|
||||
...(isCallRecordingStatusDowngrade({
|
||||
fromStatus: callRecording.status,
|
||||
toStatus: CallRecordingStatus.FAILED_UNKNOWN,
|
||||
toStatus: CallRecordingStatus.FAILED,
|
||||
})
|
||||
? {}
|
||||
: { status: CallRecordingStatus.FAILED_UNKNOWN }),
|
||||
: { status: CallRecordingStatus.FAILED }),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+1
@@ -44,6 +44,7 @@ export const persistCallRecordingProgress = async (
|
||||
const nonStatusUpdate: CallRecordingUpdateFields = { ...updateData };
|
||||
|
||||
delete nonStatusUpdate.status;
|
||||
delete nonStatusUpdate.meetingBotFailureReason;
|
||||
|
||||
if (Object.keys(nonStatusUpdate).length > 0) {
|
||||
await updateCallRecording(client, { id, data: nonStatusUpdate });
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { type CallRecordingUpdateFields } from 'src/logic-functions/data/update-
|
||||
|
||||
type CallRecordingTranscriptArtifactUpdateFields = Pick<
|
||||
CallRecordingUpdateFields,
|
||||
'status' | 'transcript'
|
||||
'meetingBotFailureReason' | 'status' | 'transcript'
|
||||
>;
|
||||
|
||||
export type ReconcileCallRecordingTranscriptArtifactResult = {
|
||||
|
||||
+4
-2
@@ -3,6 +3,7 @@ import { isNull, isUndefined } from '@sniptt/guards';
|
||||
import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
|
||||
import { buildFailedTranscriptMarker } from 'src/logic-functions/domain/build-failed-transcript-marker.util';
|
||||
import { buildPendingTranscriptMarker } from 'src/logic-functions/domain/build-pending-transcript-marker.util';
|
||||
import { buildTranscriptFailureReason } from 'src/logic-functions/domain/build-transcript-failure-reason.util';
|
||||
import { isCallRecordingStatusDowngrade } from 'src/logic-functions/domain/is-call-recording-status-downgrade.util';
|
||||
import { parseTranscriptMarker } from 'src/logic-functions/domain/parse-transcript-marker.util';
|
||||
import { createAsyncRecallTranscript } from 'src/logic-functions/recall-api/create-async-recall-transcript.util';
|
||||
@@ -172,10 +173,11 @@ const buildTranscriptFailureUpdate = ({
|
||||
recallTranscriptId: transcriptId,
|
||||
subCode,
|
||||
}),
|
||||
meetingBotFailureReason: buildTranscriptFailureReason(subCode),
|
||||
...(isCallRecordingStatusDowngrade({
|
||||
fromStatus: currentStatus,
|
||||
toStatus: CallRecordingStatus.FAILED_UNKNOWN,
|
||||
toStatus: CallRecordingStatus.FAILED,
|
||||
})
|
||||
? {}
|
||||
: { status: CallRecordingStatus.FAILED_UNKNOWN }),
|
||||
: { status: CallRecordingStatus.FAILED }),
|
||||
});
|
||||
|
||||
+7
-2
@@ -457,14 +457,19 @@ const buildPolicyManagedCallRecordingUpdateFields = ({
|
||||
calendarEvent: CalendarEventRecord;
|
||||
}): CallRecordingUpdateFields =>
|
||||
canResetCallRecordingStatusToScheduled(existingCallRecording.status)
|
||||
? buildScheduledCallRecordingFields(calendarEvent)
|
||||
? {
|
||||
...buildScheduledCallRecordingFields(calendarEvent),
|
||||
...(isUndefined(existingCallRecording.meetingBotFailureReason)
|
||||
? {}
|
||||
: { meetingBotFailureReason: null }),
|
||||
}
|
||||
: buildCalendarDrivenCallRecordingFields(calendarEvent);
|
||||
|
||||
const canResetCallRecordingStatusToScheduled = (
|
||||
status: string | undefined,
|
||||
): boolean =>
|
||||
status === CallRecordingStatus.SCHEDULED ||
|
||||
status === CallRecordingStatus.FAILED_UNKNOWN;
|
||||
status === CallRecordingStatus.FAILED;
|
||||
|
||||
const buildRemovedCalendarEventIdsByMeetingKey = (
|
||||
removedOccurrences: RemovedMeetingBotOccurrence[],
|
||||
|
||||
+19
@@ -47,6 +47,7 @@ describe('extractRecallBotConvergence', () => {
|
||||
|
||||
expect(convergence).toEqual({
|
||||
status: 'PROCESSING',
|
||||
failureReason: undefined,
|
||||
startedAt: '2026-01-01T13:02:00.000Z',
|
||||
endedAt: '2026-01-01T14:00:00.000Z',
|
||||
externalRecordingId: 'recall-recording-1',
|
||||
@@ -65,6 +66,7 @@ describe('extractRecallBotConvergence', () => {
|
||||
|
||||
expect(convergence).toEqual({
|
||||
status: 'PROCESSING',
|
||||
failureReason: undefined,
|
||||
startedAt: '2026-01-01T13:02:00.000Z',
|
||||
endedAt: '2026-01-01T14:00:00.000Z',
|
||||
externalRecordingId: 'recall-recording-1',
|
||||
@@ -93,6 +95,7 @@ describe('extractRecallBotConvergence', () => {
|
||||
it('returns nothing derivable from an empty bot response', () => {
|
||||
expect(extractRecallBotConvergence({})).toEqual({
|
||||
status: undefined,
|
||||
failureReason: undefined,
|
||||
startedAt: undefined,
|
||||
endedAt: undefined,
|
||||
externalRecordingId: undefined,
|
||||
@@ -113,6 +116,7 @@ describe('extractRecallBotConvergence', () => {
|
||||
|
||||
expect(convergence).toEqual({
|
||||
status: 'RECORDING',
|
||||
failureReason: undefined,
|
||||
startedAt: '2026-01-01T13:02:00.000Z',
|
||||
endedAt: undefined,
|
||||
externalRecordingId: undefined,
|
||||
@@ -120,6 +124,21 @@ describe('extractRecallBotConvergence', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('carries the failing Recall status code as the failure reason', () => {
|
||||
const convergence = extractRecallBotConvergence({
|
||||
status_changes: [
|
||||
{ code: 'joining_call', created_at: '2026-01-01T12:58:00.000Z' },
|
||||
{
|
||||
code: 'recording_permission_denied',
|
||||
created_at: '2026-01-01T13:02:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(convergence.status).toBe('FAILED');
|
||||
expect(convergence.failureReason).toBe('recording_permission_denied');
|
||||
});
|
||||
|
||||
it('leaves the status undefined for unknown latest codes', () => {
|
||||
const convergence = extractRecallBotConvergence({
|
||||
status_changes: [
|
||||
|
||||
+10
-2
@@ -1,6 +1,6 @@
|
||||
import { isArray, isUndefined } from '@sniptt/guards';
|
||||
|
||||
import { type CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
|
||||
import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
|
||||
import { asRecord } from 'src/logic-functions/utils/as-record.util';
|
||||
import { getString } from 'src/logic-functions/utils/get-string.util';
|
||||
import { mapRecallStatusCodeToCallRecordingStatus } from 'src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util';
|
||||
@@ -8,6 +8,7 @@ import { normalizeRecallTimestamp } from 'src/logic-functions/recall-api/normali
|
||||
|
||||
export type RecallBotConvergence = {
|
||||
status: CallRecordingStatus | undefined;
|
||||
failureReason: string | undefined;
|
||||
startedAt: string | undefined;
|
||||
endedAt: string | undefined;
|
||||
externalRecordingId: string | undefined;
|
||||
@@ -25,10 +26,17 @@ export const extractRecallBotConvergence = (
|
||||
): RecallBotConvergence => {
|
||||
const statusChanges = extractStatusChanges(bot);
|
||||
const latestStatusChange = getLatestStatusChange(statusChanges);
|
||||
const status = mapRecallStatusCodeToCallRecordingStatus(
|
||||
latestStatusChange?.code,
|
||||
);
|
||||
const recording = extractFirstRecording(bot);
|
||||
|
||||
return {
|
||||
status: mapRecallStatusCodeToCallRecordingStatus(latestStatusChange?.code),
|
||||
status,
|
||||
failureReason:
|
||||
status === CallRecordingStatus.FAILED
|
||||
? latestStatusChange?.code
|
||||
: undefined,
|
||||
startedAt: normalizeRecallTimestamp(
|
||||
recording?.startedAt ??
|
||||
findStatusChangeTimestamp(statusChanges, 'in_call_recording'),
|
||||
|
||||
+1
@@ -11,4 +11,5 @@ export type CallRecordingRecord = {
|
||||
calendarEventId?: string;
|
||||
externalBotId?: string;
|
||||
externalRecordingId?: string;
|
||||
meetingBotFailureReason?: string;
|
||||
};
|
||||
|
||||
+5
-1
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
|
||||
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
|
||||
import { BackfillSearchFieldMetadataCommand } from 'src/database/commands/upgrade-version-command/2-16/2-16-workspace-command-1799100000000-backfill-search-field-metadata.command';
|
||||
import { SyncCallRecordingStatusCommand } from 'src/database/commands/upgrade-version-command/2-16/2-16-workspace-command-1799100001000-sync-call-recording-status.command';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
|
||||
@@ -13,6 +14,9 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
|
||||
WorkspaceIteratorModule,
|
||||
WorkspaceMigrationModule,
|
||||
],
|
||||
providers: [BackfillSearchFieldMetadataCommand],
|
||||
providers: [
|
||||
BackfillSearchFieldMetadataCommand,
|
||||
SyncCallRecordingStatusCommand,
|
||||
],
|
||||
})
|
||||
export class V2_16_UpgradeVersionCommandModule {}
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
import { Command } from 'nest-commander';
|
||||
|
||||
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
|
||||
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
|
||||
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
|
||||
|
||||
const CALL_RECORDING_STATUS_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
STANDARD_OBJECTS.callRecording.fields.status.universalIdentifier;
|
||||
const CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER =
|
||||
STANDARD_OBJECTS.callRecording.universalIdentifier;
|
||||
const LEGACY_FAILED_STATUS = 'FAILED_UNKNOWN';
|
||||
const FAILED_STATUS = 'FAILED';
|
||||
|
||||
@RegisteredWorkspaceCommand('2.16.0', 1799100001000)
|
||||
@Command({
|
||||
name: 'upgrade:2-16:sync-call-recording-status',
|
||||
description:
|
||||
'Sync CallRecording status metadata from FAILED_UNKNOWN to FAILED in existing workspaces',
|
||||
})
|
||||
export class SyncCallRecordingStatusCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
|
||||
constructor(
|
||||
protected readonly workspaceIteratorService: WorkspaceIteratorService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
|
||||
) {
|
||||
super(workspaceIteratorService);
|
||||
}
|
||||
|
||||
override async runOnWorkspace({
|
||||
workspaceId,
|
||||
options,
|
||||
}: RunOnWorkspaceArgs): Promise<void> {
|
||||
const isDryRun = options.dryRun ?? false;
|
||||
|
||||
const { flatFieldMetadataMaps, flatObjectMetadataMaps } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'flatFieldMetadataMaps',
|
||||
'flatObjectMetadataMaps',
|
||||
]);
|
||||
|
||||
const callRecordingObject =
|
||||
flatObjectMetadataMaps.byUniversalIdentifier[
|
||||
CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER
|
||||
];
|
||||
|
||||
if (!isDefined(callRecordingObject)) {
|
||||
this.logger.log(
|
||||
`CallRecording object metadata does not exist for workspace ${workspaceId}, skipping`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const statusField =
|
||||
flatFieldMetadataMaps.byUniversalIdentifier[
|
||||
CALL_RECORDING_STATUS_FIELD_UNIVERSAL_IDENTIFIER
|
||||
];
|
||||
|
||||
if (!isDefined(statusField)) {
|
||||
this.logger.log(
|
||||
`CallRecording status field metadata does not exist for workspace ${workspaceId}, skipping`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusField.type !== FieldMetadataType.SELECT) {
|
||||
throw new Error(
|
||||
`CallRecording status metadata is not a SELECT field for workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const selectStatusField =
|
||||
statusField as FlatFieldMetadata<FieldMetadataType.SELECT>;
|
||||
const optionsWithFailedStatus = (selectStatusField.options ?? []).map(
|
||||
(option) =>
|
||||
option.value === LEGACY_FAILED_STATUS
|
||||
? { ...option, value: FAILED_STATUS }
|
||||
: option,
|
||||
);
|
||||
const hasLegacyFailedStatus = optionsWithFailedStatus.some(
|
||||
(option, index) =>
|
||||
option.value !== selectStatusField.options?.[index]?.value,
|
||||
);
|
||||
|
||||
if (!hasLegacyFailedStatus) {
|
||||
this.logger.log(
|
||||
`CallRecording status metadata already synced for workspace ${workspaceId}, skipping`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`${isDryRun ? '[DRY RUN] ' : ''}Syncing CallRecording status metadata from ${LEGACY_FAILED_STATUS} to ${FAILED_STATUS} for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
if (isDryRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { twentyStandardFlatApplication } =
|
||||
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
|
||||
{ workspaceId },
|
||||
);
|
||||
|
||||
const updatedStatusField: FlatFieldMetadata<FieldMetadataType.SELECT> = {
|
||||
...selectStatusField,
|
||||
options: optionsWithFailedStatus,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const validateAndBuildResult =
|
||||
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
|
||||
{
|
||||
isSystemBuild: true,
|
||||
applicationUniversalIdentifier:
|
||||
twentyStandardFlatApplication.universalIdentifier,
|
||||
workspaceId,
|
||||
allFlatEntityOperationByMetadataName: {
|
||||
fieldMetadata: {
|
||||
flatEntityToCreate: [],
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [updatedStatusField],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (validateAndBuildResult.status === 'fail') {
|
||||
throw new Error(
|
||||
`Failed to sync CallRecording status metadata for workspace ${workspaceId}: ${JSON.stringify(
|
||||
validateAndBuildResult,
|
||||
null,
|
||||
2,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Synced CallRecording status metadata from ${LEGACY_FAILED_STATUS} to ${FAILED_STATUS} for workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -175,7 +175,7 @@ export const buildCallRecordingStandardFlatFieldMetadatas = ({
|
||||
},
|
||||
{
|
||||
id: '4800777e-54a8-4464-9c01-07d6eefd04da',
|
||||
value: CallRecordingStatus.FAILED_UNKNOWN,
|
||||
value: CallRecordingStatus.FAILED,
|
||||
label: i18nLabel(msg`Failed`),
|
||||
position: 5,
|
||||
color: 'gray',
|
||||
|
||||
+1
-1
@@ -4,5 +4,5 @@ export enum CallRecordingStatus {
|
||||
RECORDING = 'RECORDING',
|
||||
PROCESSING = 'PROCESSING',
|
||||
COMPLETED = 'COMPLETED',
|
||||
FAILED_UNKNOWN = 'FAILED_UNKNOWN',
|
||||
FAILED = 'FAILED',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user