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, }, });