From e1c962acba23682a1db2598b8cc9302b41821f3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emmanuel=20Hern=C3=A1ndez=20Baz=C3=A1n?= <126545166+emmanuelhb56@users.noreply.github.com> Date: Tue, 23 Jun 2026 03:37:08 -0600 Subject: [PATCH] feat(meeting-bot): configure Recall recording retention hours (#21978) ## Summary - Sends an explicit Recall.ai recording retention policy when creating or rescheduling meeting bots. - Uses the optional server variable `MEETING_BOT_RECORDING_RETENTION_HOURS` instead of a workspace/app variable. - Defaults to `166` hours (6 days and 22 hours), keeping Twenty-hosted deployments below Recall.ai's 7-day free storage window while still allowing self-hosters to configure a longer retention period. ## Why Recall.ai accounts created after June 12, 2025 retain recording media forever unless retention is configured. Twenty ingests the meeting artifacts into its own storage, so Recall.ai media retention should be bounded by default to avoid unnecessary third-party storage cost. ## Changes - Replaces the days-based app variable with the server variable `MEETING_BOT_RECORDING_RETENTION_HOURS`. - Adds a default retention constant of `166` hours. - Builds `recording_config.retention = { type: 'timed', hours }` centrally through `getRecallBotRecordingConfig()`. - Applies the same recording config to both bot creation and bot rescheduling. - Documents the server variable and warns that values above `168` hours may incur Recall.ai storage charges. - Updates Recall API tests to assert retention is sent and invalid values fall back to the safe default. ## QA - [x] `yarn test:unit` - [x] `yarn lint` - [x] `yarn exec tsc --noEmit -p tsconfig.spec.json` - [x] `git diff --check` - [x] Live Recall.ai bot payload includes `recording_config.retention = { type: 'timed', hours: 166 }` --------- Co-authored-by: Emmanuel Hernandez Co-authored-by: Claude Sonnet 4.6 Co-authored-by: ehconitin --- .../internal/twenty-meeting-bot/README.md | 3 + .../src/application-config.ts | 6 ++ ...t-meeting-bot-recording-retention-hours.ts | 2 + ...-recording-retention-hours-env-var-name.ts | 2 + .../constants/recall-bot-recording-config.ts | 37 ++++++++++-- .../__tests__/recall-bot-api.test.ts | 59 +++++++++++++++++++ .../recall-api/reschedule-recall-bot.util.ts | 4 +- .../recall-api/schedule-recall-bot.util.ts | 4 +- 8 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/default-meeting-bot-recording-retention-hours.ts create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/meeting-bot-recording-retention-hours-env-var-name.ts diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/README.md b/packages/twenty-apps/internal/twenty-meeting-bot/README.md index a166e00b5a..aa60f0d69c 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/README.md +++ b/packages/twenty-apps/internal/twenty-meeting-bot/README.md @@ -25,8 +25,11 @@ A server admin configures Recall credentials through server variables on the app | --- | --- | --- | | `RECALL_API_KEY` | Yes | Recall.ai API key for the configured region; used to schedule, update, and cancel bots. | | `RECALL_REGION` | No | Recall.ai region for API requests. Defaults to `eu-central-1`. | +| `MEETING_BOT_RECORDING_RETENTION_HOURS` | No | How many hours Recall.ai retains recording media after processing. Defaults to `166` hours (6 days and 22 hours) to stay below Recall.ai's 7-day free storage window. Values above `168` hours may incur Recall.ai storage charges. | | `RECALL_WEBHOOK_SECRET` | Yes | Svix signing secret (`whsec_…`) used to verify incoming Recall webhooks. | +Recall.ai retention only controls Recall.ai's copy of the recording media. Twenty stores ingested transcript and video artifacts in its own storage, so deleting Recall.ai media after the retention window does not remove the artifacts already stored in Twenty. + A workspace admin can adjust bot behavior through application variables: | Application variable | Default | Purpose | diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/application-config.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/application-config.ts index 4ed3146780..fbb0f021ea 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/application-config.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/application-config.ts @@ -10,6 +10,7 @@ import { MEETING_BOT_NOONE_JOINED_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIF import { MEETING_BOT_WAITING_ROOM_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/meeting-bot-waiting-room-timeout-seconds-app-variable-universal-identifier'; import { DEFAULT_MEETING_BOT_JOIN_EARLY_MINUTES } from 'src/logic-functions/constants/default-meeting-bot-join-early-minutes'; import { DEFAULT_MEETING_BOT_NAME } from 'src/logic-functions/constants/default-meeting-bot-name'; +import { DEFAULT_MEETING_BOT_RECORDING_RETENTION_HOURS } from 'src/logic-functions/constants/default-meeting-bot-recording-retention-hours'; import { DEFAULT_RECALL_REGION } from 'src/logic-functions/constants/default-recall-region'; import { MEETING_BOT_EVERYONE_LEFT_TIMEOUT_SECONDS } from 'src/logic-functions/constants/meeting-bot-everyone-left-timeout-seconds'; import { MEETING_BOT_EVERYONE_LEFT_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/meeting-bot-everyone-left-timeout-seconds-env-var-name'; @@ -17,6 +18,7 @@ import { MEETING_BOT_JOIN_EARLY_MINUTES_ENV_VAR_NAME } from 'src/logic-functions import { MEETING_BOT_NAME_ENV_VAR_NAME } from 'src/logic-functions/constants/meeting-bot-name-env-var-name'; import { MEETING_BOT_NOONE_JOINED_TIMEOUT_SECONDS } from 'src/logic-functions/constants/meeting-bot-noone-joined-timeout-seconds'; import { MEETING_BOT_NOONE_JOINED_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/meeting-bot-noone-joined-timeout-seconds-env-var-name'; +import { MEETING_BOT_RECORDING_RETENTION_HOURS_ENV_VAR_NAME } from 'src/logic-functions/constants/meeting-bot-recording-retention-hours-env-var-name'; import { MEETING_BOT_WAITING_ROOM_TIMEOUT_SECONDS } from 'src/logic-functions/constants/meeting-bot-waiting-room-timeout-seconds'; import { MEETING_BOT_WAITING_ROOM_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/meeting-bot-waiting-room-timeout-seconds-env-var-name'; import { RECALL_API_KEY_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-api-key-env-var-name'; @@ -79,6 +81,10 @@ export default defineApplication({ description: `Recall.ai region used for API requests. Defaults to ${DEFAULT_RECALL_REGION} when unset. Europe Frankfurt is eu-central-1.`, isSecret: false, }, + [MEETING_BOT_RECORDING_RETENTION_HOURS_ENV_VAR_NAME]: { + description: `How many hours Recall.ai retains recording media after processing. Defaults to ${DEFAULT_MEETING_BOT_RECORDING_RETENTION_HOURS} hours (6 days and 22 hours) to stay below Recall.ai's 7-day free storage window. Values above 168 hours may incur Recall.ai storage charges.`, + isSecret: false, + }, [RECALL_WEBHOOK_SECRET_ENV_VAR_NAME]: { description: 'Recall.ai webhook signing secret (whsec_...). Set by the server admin from the Recall webhook endpoint settings; used to verify the Svix signature of incoming Recall webhook deliveries.', diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/default-meeting-bot-recording-retention-hours.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/default-meeting-bot-recording-retention-hours.ts new file mode 100644 index 0000000000..3ec7807e42 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/default-meeting-bot-recording-retention-hours.ts @@ -0,0 +1,2 @@ +// Twenty stores ingested recording artifacts, so Recall.ai media is temporary. Keep the default below Recall.ai's 168-hour free storage window. +export const DEFAULT_MEETING_BOT_RECORDING_RETENTION_HOURS = 166; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/meeting-bot-recording-retention-hours-env-var-name.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/meeting-bot-recording-retention-hours-env-var-name.ts new file mode 100644 index 0000000000..f8a6573c9e --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/meeting-bot-recording-retention-hours-env-var-name.ts @@ -0,0 +1,2 @@ +export const MEETING_BOT_RECORDING_RETENTION_HOURS_ENV_VAR_NAME = + 'MEETING_BOT_RECORDING_RETENTION_HOURS'; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-recording-config.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-recording-config.ts index 6f1102e830..7c6655b581 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-recording-config.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-recording-config.ts @@ -1,5 +1,34 @@ -// Recall only produces artifacts declared at bot creation; both gate COMPLETED. -export const RECALL_BOT_RECORDING_CONFIG = { - video_mixed_mp4: {}, - audio_mixed_mp3: {}, +import { DEFAULT_MEETING_BOT_RECORDING_RETENTION_HOURS } from 'src/logic-functions/constants/default-meeting-bot-recording-retention-hours'; +import { MEETING_BOT_RECORDING_RETENTION_HOURS_ENV_VAR_NAME } from 'src/logic-functions/constants/meeting-bot-recording-retention-hours-env-var-name'; +import { getApplicationVariableValue } from 'src/logic-functions/utils/get-application-variable-value.util'; +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; + +type RecallBotRecordingConfig = { + video_mixed_mp4: Record; + audio_mixed_mp3: Record; + retention: { type: 'timed'; hours: number }; +}; + +// Recall only produces artifacts declared at bot creation; both gate COMPLETED. +export const getRecallBotRecordingConfig = (): RecallBotRecordingConfig => { + const configuredRecordingRetentionHours = getApplicationVariableValue( + MEETING_BOT_RECORDING_RETENTION_HOURS_ENV_VAR_NAME, + ); + + const recordingRetentionHours = isNonEmptyString( + configuredRecordingRetentionHours, + ) + ? Number(configuredRecordingRetentionHours.trim()) + : NaN; + + const resolvedRecordingRetentionHours = + Number.isInteger(recordingRetentionHours) && recordingRetentionHours > 0 + ? recordingRetentionHours + : DEFAULT_MEETING_BOT_RECORDING_RETENTION_HOURS; + + return { + video_mixed_mp4: {}, + audio_mixed_mp3: {}, + retention: { type: 'timed', hours: resolvedRecordingRetentionHours }, + }; }; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/recall-bot-api.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/recall-bot-api.test.ts index 6f4e912622..8e301c55d9 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/recall-bot-api.test.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/recall-bot-api.test.ts @@ -9,6 +9,7 @@ import { listScheduledRecallBots } from 'src/logic-functions/recall-api/list-sch import { rescheduleRecallBot } from 'src/logic-functions/recall-api/reschedule-recall-bot.util'; import { retrieveRecallTranscript } from 'src/logic-functions/recall-api/retrieve-recall-transcript.util'; import { scheduleRecallBot } from 'src/logic-functions/recall-api/schedule-recall-bot.util'; +import { MEETING_BOT_RECORDING_RETENTION_HOURS_ENV_VAR_NAME } from 'src/logic-functions/constants/meeting-bot-recording-retention-hours-env-var-name'; const getRecallApiConfigMock = vi.hoisted(() => vi.fn()); const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000'; @@ -21,6 +22,7 @@ describe('recall bot api', () => { const fetchMock = vi.fn(); beforeEach(() => { + delete process.env[MEETING_BOT_RECORDING_RETENTION_HOURS_ENV_VAR_NAME]; getRecallApiConfigMock.mockReset(); getRecallApiConfigMock.mockReturnValue({ success: true, @@ -69,6 +71,7 @@ describe('recall bot api', () => { recording_config: { video_mixed_mp4: {}, audio_mixed_mp3: {}, + retention: { type: 'timed', hours: 166 }, }, metadata: { twentyWorkspaceId: WORKSPACE_ID, @@ -79,6 +82,55 @@ describe('recall bot api', () => { }); }); + it('uses the configured Recall recording retention hours when scheduling a bot', async () => { + process.env[MEETING_BOT_RECORDING_RETENTION_HOURS_ENV_VAR_NAME] = '240'; + + 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', + }, + }); + + expect(result).toEqual({ ok: true, externalBotId: 'recall-bot-id' }); + expect(JSON.parse(fetchMock.mock.calls[0][1].body).recording_config).toEqual( + { + video_mixed_mp4: {}, + audio_mixed_mp3: {}, + retention: { type: 'timed', hours: 240 }, + }, + ); + }); + + it('falls back to safe Recall recording retention hours when the configured value is invalid', async () => { + process.env[MEETING_BOT_RECORDING_RETENTION_HOURS_ENV_VAR_NAME] = + 'seven-days'; + + 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', + }, + }); + + expect(result).toEqual({ ok: true, externalBotId: 'recall-bot-id' }); + expect(JSON.parse(fetchMock.mock.calls[0][1].body).recording_config).toEqual( + { + video_mixed_mp4: {}, + audio_mixed_mp3: {}, + retention: { type: 'timed', hours: 166 }, + }, + ); + }); + it('fails when the create response does not include a bot id', async () => { fetchMock.mockResolvedValue({ ok: true, @@ -130,6 +182,13 @@ describe('recall bot api', () => { errorMessage: 'Recall API responded with HTTP 404: {"detail":"Not found."}', }); + expect(JSON.parse(fetchMock.mock.calls[0][1].body).recording_config).toEqual( + { + video_mixed_mp4: {}, + audio_mixed_mp3: {}, + retention: { type: 'timed', hours: 166 }, + }, + ); }); it('does not duplicate an existing Token authorization prefix', async () => { diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/reschedule-recall-bot.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/reschedule-recall-bot.util.ts index b22455fa73..cf6cc2d0f8 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/reschedule-recall-bot.util.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/reschedule-recall-bot.util.ts @@ -1,7 +1,7 @@ import { isUndefined } from '@sniptt/guards'; import { getRecallBotAutomaticLeave } from 'src/logic-functions/constants/recall-bot-automatic-leave'; -import { RECALL_BOT_RECORDING_CONFIG } from 'src/logic-functions/constants/recall-bot-recording-config'; +import { getRecallBotRecordingConfig } from 'src/logic-functions/constants/recall-bot-recording-config'; import { type RecallBotScheduleResult } from 'src/logic-functions/types/recall-bot-operation-result.type'; import { extractRecallBotId, @@ -38,7 +38,7 @@ export const rescheduleRecallBot = async ({ join_at: joinAt, bot_name: configResult.config.botName, ...(isUndefined(automaticLeave) ? {} : { automatic_leave: automaticLeave }), - recording_config: RECALL_BOT_RECORDING_CONFIG, + recording_config: getRecallBotRecordingConfig(), metadata, }, }); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/schedule-recall-bot.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/schedule-recall-bot.util.ts index 9fa8e79bbc..8de80b44e1 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/schedule-recall-bot.util.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/schedule-recall-bot.util.ts @@ -1,7 +1,7 @@ import { isUndefined } from '@sniptt/guards'; import { getRecallBotAutomaticLeave } from 'src/logic-functions/constants/recall-bot-automatic-leave'; -import { RECALL_BOT_RECORDING_CONFIG } from 'src/logic-functions/constants/recall-bot-recording-config'; +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 RecallBotScheduleResult } from 'src/logic-functions/types/recall-bot-operation-result.type'; import { @@ -39,7 +39,7 @@ export const scheduleRecallBot = async ({ join_at: joinAt, bot_name: configResult.config.botName, ...(isUndefined(automaticLeave) ? {} : { automatic_leave: automaticLeave }), - recording_config: RECALL_BOT_RECORDING_CONFIG, + recording_config: getRecallBotRecordingConfig(), metadata, }, });