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 <emmanuel.hernandez@clickbalance.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: ehconitin <nitinkoche03@gmail.com>
This commit is contained in:
committed by
GitHub
parent
6f50b9d01e
commit
e1c962acba
@@ -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 |
|
||||
|
||||
@@ -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.',
|
||||
|
||||
+2
@@ -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;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const MEETING_BOT_RECORDING_RETENTION_HOURS_ENV_VAR_NAME =
|
||||
'MEETING_BOT_RECORDING_RETENTION_HOURS';
|
||||
+33
-4
@@ -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<string, never>;
|
||||
audio_mixed_mp3: Record<string, never>;
|
||||
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 },
|
||||
};
|
||||
};
|
||||
|
||||
+59
@@ -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 () => {
|
||||
|
||||
+2
-2
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
+2
-2
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user