diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/README.md b/packages/twenty-apps/internal/twenty-meeting-bot/README.md index 1c7028611d..253ef3ff84 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/README.md +++ b/packages/twenty-apps/internal/twenty-meeting-bot/README.md @@ -17,7 +17,9 @@ Run `yarn twenty help` to list all available commands. ## Recall.ai configuration -This app schedules Recall.ai meeting bots and ingests their lifecycle events. A server admin configures it through server variables on the application registration (Settings → Applications → Twenty Meeting Bot): +This app schedules Recall.ai meeting bots and ingests their lifecycle events. + +A server admin configures Recall credentials through server variables on the application registration (Settings → Applications → Twenty Meeting Bot): | Server variable | Required | Purpose | | --- | --- | --- | @@ -25,6 +27,16 @@ This app schedules Recall.ai meeting bots and ingests their lifecycle events. A | `RECALL_REGION` | No | Recall.ai region for API requests. Defaults to `eu-central-1`. | | `RECALL_WEBHOOK_SECRET` | Yes | Svix signing secret (`whsec_…`) used to verify incoming Recall webhooks. | +A workspace admin can adjust bot behavior through application variables: + +| Application variable | Default | Purpose | +| --- | --- | --- | +| `RECALL_BOT_NAME` | `Twenty Meeting Bot` | Display name used when scheduling Recall.ai meeting bots. | +| `RECALL_BOT_JOIN_EARLY_MINUTES` | `1` | How many minutes before the meeting start time the bot should join. Set to `0` to join at the scheduled start time. | +| `RECALL_BOT_WAITING_ROOM_TIMEOUT_SECONDS` | `1200` | How many seconds the bot waits in a meeting lobby before giving up and leaving. | +| `RECALL_BOT_NOONE_JOINED_TIMEOUT_SECONDS` | `1200` | How many seconds the bot stays in an empty meeting when no one else ever joins. | +| `RECALL_BOT_EVERYONE_LEFT_TIMEOUT_SECONDS` | `2` | How many seconds the bot keeps recording after everyone else leaves the meeting. | + ### 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`). 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 dc6c252918..437fb57a18 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 @@ -3,11 +3,23 @@ import { defineApplication } from 'twenty-sdk/define'; import { APP_DESCRIPTION } from 'src/constants/app-description'; import { APP_DISPLAY_NAME } from 'src/constants/app-display-name'; import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/application-universal-identifier'; +import { RECALL_BOT_EVERYONE_LEFT_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/recall-bot-everyone-left-timeout-seconds-app-variable-universal-identifier'; +import { RECALL_BOT_JOIN_EARLY_MINUTES_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/recall-bot-join-early-minutes-app-variable-universal-identifier'; import { RECALL_BOT_NAME_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/recall-bot-name-app-variable-universal-identifier'; +import { RECALL_BOT_NOONE_JOINED_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/recall-bot-noone-joined-timeout-seconds-app-variable-universal-identifier'; +import { RECALL_BOT_WAITING_ROOM_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/recall-bot-waiting-room-timeout-seconds-app-variable-universal-identifier'; +import { DEFAULT_RECALL_BOT_JOIN_EARLY_MINUTES } from 'src/logic-functions/constants/default-recall-bot-join-early-minutes'; import { DEFAULT_RECALL_BOT_NAME } from 'src/logic-functions/constants/default-recall-bot-name'; import { DEFAULT_RECALL_REGION } from 'src/logic-functions/constants/default-recall-region'; import { RECALL_API_KEY_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-api-key-env-var-name'; +import { RECALL_BOT_EVERYONE_LEFT_TIMEOUT_SECONDS } from 'src/logic-functions/constants/recall-bot-everyone-left-timeout-seconds'; +import { RECALL_BOT_EVERYONE_LEFT_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-bot-everyone-left-timeout-seconds-env-var-name'; +import { RECALL_BOT_JOIN_EARLY_MINUTES_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-bot-join-early-minutes-env-var-name'; import { RECALL_BOT_NAME_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-bot-name-env-var-name'; +import { RECALL_BOT_NOONE_JOINED_TIMEOUT_SECONDS } from 'src/logic-functions/constants/recall-bot-noone-joined-timeout-seconds'; +import { RECALL_BOT_NOONE_JOINED_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-bot-noone-joined-timeout-seconds-env-var-name'; +import { RECALL_BOT_WAITING_ROOM_TIMEOUT_SECONDS } from 'src/logic-functions/constants/recall-bot-waiting-room-timeout-seconds'; +import { RECALL_BOT_WAITING_ROOM_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-bot-waiting-room-timeout-seconds-env-var-name'; import { RECALL_REGION_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-region-env-var-name'; import { RECALL_WEBHOOK_SECRET_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-webhook-secret-env-var-name'; @@ -23,6 +35,38 @@ export default defineApplication({ isSecret: false, value: DEFAULT_RECALL_BOT_NAME, }, + [RECALL_BOT_JOIN_EARLY_MINUTES_ENV_VAR_NAME]: { + universalIdentifier: + RECALL_BOT_JOIN_EARLY_MINUTES_APP_VARIABLE_UNIVERSAL_IDENTIFIER, + description: + 'How many minutes before the meeting start time the bot should join. Set to 0 to join at the scheduled start time.', + isSecret: false, + value: String(DEFAULT_RECALL_BOT_JOIN_EARLY_MINUTES), + }, + [RECALL_BOT_WAITING_ROOM_TIMEOUT_SECONDS_ENV_VAR_NAME]: { + universalIdentifier: + RECALL_BOT_WAITING_ROOM_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER, + description: + 'How many seconds the bot waits in a meeting lobby before giving up and leaving.', + isSecret: false, + value: String(RECALL_BOT_WAITING_ROOM_TIMEOUT_SECONDS), + }, + [RECALL_BOT_NOONE_JOINED_TIMEOUT_SECONDS_ENV_VAR_NAME]: { + universalIdentifier: + RECALL_BOT_NOONE_JOINED_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER, + description: + 'How many seconds the bot stays in an empty meeting when no one else ever joins.', + isSecret: false, + value: String(RECALL_BOT_NOONE_JOINED_TIMEOUT_SECONDS), + }, + [RECALL_BOT_EVERYONE_LEFT_TIMEOUT_SECONDS_ENV_VAR_NAME]: { + universalIdentifier: + RECALL_BOT_EVERYONE_LEFT_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER, + description: + 'How many seconds the bot keeps recording after everyone else leaves the meeting.', + isSecret: false, + value: String(RECALL_BOT_EVERYONE_LEFT_TIMEOUT_SECONDS), + }, }, serverVariables: { [RECALL_API_KEY_ENV_VAR_NAME]: { diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/recall-bot-everyone-left-timeout-seconds-app-variable-universal-identifier.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/recall-bot-everyone-left-timeout-seconds-app-variable-universal-identifier.ts new file mode 100644 index 0000000000..3555cd8546 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/recall-bot-everyone-left-timeout-seconds-app-variable-universal-identifier.ts @@ -0,0 +1,2 @@ +export const RECALL_BOT_EVERYONE_LEFT_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER = + 'c866ddd4-fb7b-4cb4-8ad1-5599755e495c'; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/recall-bot-join-early-minutes-app-variable-universal-identifier.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/recall-bot-join-early-minutes-app-variable-universal-identifier.ts new file mode 100644 index 0000000000..6b41f80c9f --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/recall-bot-join-early-minutes-app-variable-universal-identifier.ts @@ -0,0 +1,2 @@ +export const RECALL_BOT_JOIN_EARLY_MINUTES_APP_VARIABLE_UNIVERSAL_IDENTIFIER = + '0568ebb2-3f64-47de-8c0d-d367dfbb7462'; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/recall-bot-noone-joined-timeout-seconds-app-variable-universal-identifier.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/recall-bot-noone-joined-timeout-seconds-app-variable-universal-identifier.ts new file mode 100644 index 0000000000..cd9da50911 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/recall-bot-noone-joined-timeout-seconds-app-variable-universal-identifier.ts @@ -0,0 +1,2 @@ +export const RECALL_BOT_NOONE_JOINED_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER = + '241180e8-d864-4160-ad02-db44a9e8d395'; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/recall-bot-waiting-room-timeout-seconds-app-variable-universal-identifier.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/recall-bot-waiting-room-timeout-seconds-app-variable-universal-identifier.ts new file mode 100644 index 0000000000..e2b81c227a --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/recall-bot-waiting-room-timeout-seconds-app-variable-universal-identifier.ts @@ -0,0 +1,2 @@ +export const RECALL_BOT_WAITING_ROOM_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER = + '12e4e14d-d539-4d07-b477-4773539dd20b'; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/default-recall-bot-join-early-minutes.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/default-recall-bot-join-early-minutes.ts new file mode 100644 index 0000000000..cbaa7b1a80 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/default-recall-bot-join-early-minutes.ts @@ -0,0 +1 @@ +export const DEFAULT_RECALL_BOT_JOIN_EARLY_MINUTES = 1; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/milliseconds-per-minute.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/milliseconds-per-minute.ts new file mode 100644 index 0000000000..c72bff9db3 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/milliseconds-per-minute.ts @@ -0,0 +1 @@ +export const MILLISECONDS_PER_MINUTE = 60_000; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-automatic-leave.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-automatic-leave.ts index 1d1c9be90d..57ef4b2b29 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-automatic-leave.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-automatic-leave.ts @@ -1,7 +1,73 @@ -import { RECALL_BOT_NOONE_JOINED_TIMEOUT_SECONDS } from 'src/logic-functions/constants/recall-bot-noone-joined-timeout-seconds'; -import { RECALL_BOT_WAITING_ROOM_TIMEOUT_SECONDS } from 'src/logic-functions/constants/recall-bot-waiting-room-timeout-seconds'; +import { isUndefined } from '@sniptt/guards'; -export const RECALL_BOT_AUTOMATIC_LEAVE = { - waiting_room_timeout: RECALL_BOT_WAITING_ROOM_TIMEOUT_SECONDS, - noone_joined_timeout: RECALL_BOT_NOONE_JOINED_TIMEOUT_SECONDS, +import { RECALL_BOT_EVERYONE_LEFT_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-bot-everyone-left-timeout-seconds-env-var-name'; +import { RECALL_BOT_NOONE_JOINED_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-bot-noone-joined-timeout-seconds-env-var-name'; +import { RECALL_BOT_WAITING_ROOM_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-bot-waiting-room-timeout-seconds-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 RecallBotAutomaticLeave = { + waiting_room_timeout?: number; + noone_joined_timeout?: number; + everyone_left_timeout?: { + timeout: number; + activate_after: number; + }; }; + +export const getRecallBotAutomaticLeave = (): + | RecallBotAutomaticLeave + | undefined => { + const waitingRoomTimeoutSeconds = getOptionalPositiveIntegerVariable( + RECALL_BOT_WAITING_ROOM_TIMEOUT_SECONDS_ENV_VAR_NAME, + ); + const nooneJoinedTimeoutSeconds = getOptionalPositiveIntegerVariable( + RECALL_BOT_NOONE_JOINED_TIMEOUT_SECONDS_ENV_VAR_NAME, + ); + const everyoneLeftTimeoutSeconds = getOptionalPositiveIntegerVariable( + RECALL_BOT_EVERYONE_LEFT_TIMEOUT_SECONDS_ENV_VAR_NAME, + ); + + const automaticLeave: RecallBotAutomaticLeave = {}; + + if (!isUndefined(waitingRoomTimeoutSeconds)) { + automaticLeave.waiting_room_timeout = waitingRoomTimeoutSeconds; + } + + if (!isUndefined(nooneJoinedTimeoutSeconds)) { + automaticLeave.noone_joined_timeout = nooneJoinedTimeoutSeconds; + } + + if (!isUndefined(everyoneLeftTimeoutSeconds)) { + automaticLeave.everyone_left_timeout = { + timeout: everyoneLeftTimeoutSeconds, + activate_after: 0, + }; + } + + return Object.keys(automaticLeave).length === 0 ? undefined : automaticLeave; +}; + +const getOptionalPositiveIntegerVariable = ( + variableName: string, +): number | undefined => { + const rawValue = normalizeOptionalString( + getApplicationVariableValue(variableName), + ); + + if (isUndefined(rawValue)) { + return undefined; + } + + const timeoutSeconds = Number(rawValue); + + if (!Number.isInteger(timeoutSeconds) || timeoutSeconds <= 0) { + return undefined; + } + + return timeoutSeconds; +}; + +const normalizeOptionalString = ( + value: string | undefined, +): string | undefined => (isNonEmptyString(value) ? value.trim() : undefined); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-everyone-left-timeout-seconds-env-var-name.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-everyone-left-timeout-seconds-env-var-name.ts new file mode 100644 index 0000000000..687eb60710 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-everyone-left-timeout-seconds-env-var-name.ts @@ -0,0 +1,2 @@ +export const RECALL_BOT_EVERYONE_LEFT_TIMEOUT_SECONDS_ENV_VAR_NAME = + 'RECALL_BOT_EVERYONE_LEFT_TIMEOUT_SECONDS'; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-everyone-left-timeout-seconds.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-everyone-left-timeout-seconds.ts new file mode 100644 index 0000000000..94c5f14702 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-everyone-left-timeout-seconds.ts @@ -0,0 +1 @@ +export const RECALL_BOT_EVERYONE_LEFT_TIMEOUT_SECONDS = 2; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-join-early-minutes-env-var-name.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-join-early-minutes-env-var-name.ts new file mode 100644 index 0000000000..1d69f87609 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-join-early-minutes-env-var-name.ts @@ -0,0 +1,2 @@ +export const RECALL_BOT_JOIN_EARLY_MINUTES_ENV_VAR_NAME = + 'RECALL_BOT_JOIN_EARLY_MINUTES'; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-noone-joined-timeout-seconds-env-var-name.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-noone-joined-timeout-seconds-env-var-name.ts new file mode 100644 index 0000000000..be178a9109 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-noone-joined-timeout-seconds-env-var-name.ts @@ -0,0 +1,2 @@ +export const RECALL_BOT_NOONE_JOINED_TIMEOUT_SECONDS_ENV_VAR_NAME = + 'RECALL_BOT_NOONE_JOINED_TIMEOUT_SECONDS'; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-waiting-room-timeout-seconds-env-var-name.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-waiting-room-timeout-seconds-env-var-name.ts new file mode 100644 index 0000000000..673c992333 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-waiting-room-timeout-seconds-env-var-name.ts @@ -0,0 +1,2 @@ +export const RECALL_BOT_WAITING_ROOM_TIMEOUT_SECONDS_ENV_VAR_NAME = + 'RECALL_BOT_WAITING_ROOM_TIMEOUT_SECONDS'; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/stale-bot-state-cron-pattern.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/stale-bot-state-cron-pattern.ts index 0133de3231..a191b0af71 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/stale-bot-state-cron-pattern.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/stale-bot-state-cron-pattern.ts @@ -1 +1 @@ -export const STALE_BOT_STATE_CRON_PATTERN = '*/15 * * * *'; +export const STALE_BOT_STATE_CRON_PATTERN = '*/5 * * * *'; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/compute-call-recording-charge.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/compute-call-recording-charge.util.ts index 8109218471..b7d58291bb 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/compute-call-recording-charge.util.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/compute-call-recording-charge.util.ts @@ -1,9 +1,9 @@ import { isUndefined } from '@sniptt/guards'; import { CALL_RECORDING_MICRO_CREDITS_PER_HOUR } from 'src/logic-functions/constants/call-recording-micro-credits-per-hour'; +import { MILLISECONDS_PER_MINUTE } from 'src/logic-functions/constants/milliseconds-per-minute'; const MILLISECONDS_PER_HOUR = 3_600_000; -const MILLISECONDS_PER_MINUTE = 60_000; export type CallRecordingCharge = { creditsUsedMicro: number; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/compute-recall-bot-join-at.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/compute-recall-bot-join-at.util.ts new file mode 100644 index 0000000000..bf6355a9a0 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/compute-recall-bot-join-at.util.ts @@ -0,0 +1,34 @@ +import { DEFAULT_RECALL_BOT_JOIN_EARLY_MINUTES } from 'src/logic-functions/constants/default-recall-bot-join-early-minutes'; +import { MILLISECONDS_PER_MINUTE } from 'src/logic-functions/constants/milliseconds-per-minute'; +import { RECALL_BOT_JOIN_EARLY_MINUTES_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-bot-join-early-minutes-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'; + +export const computeRecallBotJoinAt = (meetingStartsAt: string): string => { + const meetingStartTimeInMilliseconds = new Date(meetingStartsAt).getTime(); + + if (Number.isNaN(meetingStartTimeInMilliseconds)) { + return meetingStartsAt; + } + + return new Date( + meetingStartTimeInMilliseconds - + getRecallBotJoinEarlyMinutes() * MILLISECONDS_PER_MINUTE, + ).toISOString(); +}; + +const getRecallBotJoinEarlyMinutes = (): number => { + const rawValue = getApplicationVariableValue( + RECALL_BOT_JOIN_EARLY_MINUTES_ENV_VAR_NAME, + ); + + if (!isNonEmptyString(rawValue)) { + return DEFAULT_RECALL_BOT_JOIN_EARLY_MINUTES; + } + + const joinEarlyMinutes = Number(rawValue.trim()); + + return Number.isInteger(joinEarlyMinutes) && joinEarlyMinutes >= 0 + ? joinEarlyMinutes + : DEFAULT_RECALL_BOT_JOIN_EARLY_MINUTES; +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/reconcile-meeting-bot.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/reconcile-meeting-bot.test.ts index fd817d45da..f7bebea769 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/reconcile-meeting-bot.test.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/reconcile-meeting-bot.test.ts @@ -22,6 +22,7 @@ vi.mock('src/logic-functions/recall-api/cancel-recall-bot.util', () => ({ const NOW = new Date('2026-01-01T12:00:00.000Z'); const FUTURE_STARTS_AT = '2026-01-01T13:00:00.000Z'; +const FUTURE_RECALL_BOT_JOIN_AT = '2026-01-01T12:59:00.000Z'; const FUTURE_ENDS_AT = '2026-01-01T14:00:00.000Z'; const buildCustomerSyncCallRecordingId = ( @@ -252,7 +253,7 @@ describe('reconcileMeetingBotForCalendarEventIds', () => { ]); expect(scheduleRecallBotMock).toHaveBeenCalledWith({ meetingUrl: 'https://meet.example.com/customer-sync', - joinAt: FUTURE_STARTS_AT, + joinAt: FUTURE_RECALL_BOT_JOIN_AT, metadata: { twentyCallRecordingId: buildCustomerSyncCallRecordingId(), twentyCalendarEventId: 'calendar-event-1', @@ -402,7 +403,7 @@ describe('reconcileMeetingBotForCalendarEventIds', () => { expect(rescheduleRecallBotMock).toHaveBeenCalledWith({ externalBotId: 'recall-bot-1', meetingUrl: 'https://meet.example.com/customer-sync', - joinAt: FUTURE_STARTS_AT, + joinAt: FUTURE_RECALL_BOT_JOIN_AT, metadata: { twentyCallRecordingId: buildCustomerSyncCallRecordingId(), twentyCalendarEventId: 'calendar-event-1', @@ -657,6 +658,7 @@ describe('reconcileMeetingBotForCalendarEventIds', () => { it('cancels the old occurrence and creates a fresh recording when the meeting moves to a new time', async () => { const NEW_STARTS_AT = '2026-01-02T13:00:00.000Z'; + const NEW_RECALL_BOT_JOIN_AT = '2026-01-02T12:59:00.000Z'; const NEW_ENDS_AT = '2026-01-02T14:00:00.000Z'; const client = buildFakeCoreApiClient({ calendarEvents: [ @@ -706,7 +708,7 @@ describe('reconcileMeetingBotForCalendarEventIds', () => { externalBotId: 'recall-bot-old', }); expect(scheduleRecallBotMock).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ joinAt: NEW_STARTS_AT }), + expect.objectContaining({ joinAt: NEW_RECALL_BOT_JOIN_AT }), ); expect(client.callRecordings).toEqual([ expect.objectContaining({ diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/ensure-meeting-bot.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/ensure-meeting-bot.util.ts index 0c4215e38d..286d1aad64 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/ensure-meeting-bot.util.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/ensure-meeting-bot.util.ts @@ -4,6 +4,7 @@ import { CoreApiClient } from 'twenty-client-sdk/core'; import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status'; import { type MeetingRecording } from 'src/logic-functions/types/meeting-recording.type'; import { buildRecallBotMetadata } from 'src/logic-functions/domain/build-recall-bot-metadata.util'; +import { computeRecallBotJoinAt } from 'src/logic-functions/domain/compute-recall-bot-join-at.util'; import { findCallRecordingsByIds } from 'src/logic-functions/data/find-call-recordings-by-ids.util'; import { scheduleRecallBot } from 'src/logic-functions/recall-api/schedule-recall-bot.util'; import { updateCallRecording } from 'src/logic-functions/data/update-call-recording.util'; @@ -14,12 +15,14 @@ export const ensureMeetingBot = async ( { callRecording, calendarEvent }: MeetingRecording, ): Promise => { const meetingUrl = calendarEvent.conferenceLinkUrl; - const joinAt = calendarEvent.startsAt; + const meetingStartsAt = calendarEvent.startsAt; - if (isUndefined(meetingUrl) || isUndefined(joinAt)) { + if (isUndefined(meetingUrl) || isUndefined(meetingStartsAt)) { return false; } + const joinAt = computeRecallBotJoinAt(meetingStartsAt); + const freshCallRecording = ( await findCallRecordingsByIds(client, [callRecording.id]) )[0]; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reschedule-call-recording-bot.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reschedule-call-recording-bot.util.ts index 6ceef30afc..693670ece9 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reschedule-call-recording-bot.util.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reschedule-call-recording-bot.util.ts @@ -3,6 +3,7 @@ import { CoreApiClient } from 'twenty-client-sdk/core'; import { type MeetingRecording } from 'src/logic-functions/types/meeting-recording.type'; import { buildRecallBotMetadata } from 'src/logic-functions/domain/build-recall-bot-metadata.util'; +import { computeRecallBotJoinAt } from 'src/logic-functions/domain/compute-recall-bot-join-at.util'; import { rescheduleRecallBot } from 'src/logic-functions/recall-api/reschedule-recall-bot.util'; import { updateCallRecording } from 'src/logic-functions/data/update-call-recording.util'; @@ -19,12 +20,14 @@ export const rescheduleCallRecordingBot = async ( } const meetingUrl = calendarEvent.conferenceLinkUrl; - const joinAt = calendarEvent.startsAt; + const meetingStartsAt = calendarEvent.startsAt; - if (isUndefined(meetingUrl) || isUndefined(joinAt)) { + if (isUndefined(meetingUrl) || isUndefined(meetingStartsAt)) { return; } + const joinAt = computeRecallBotJoinAt(meetingStartsAt); + const rescheduleResult = await rescheduleRecallBot({ externalBotId, meetingUrl, 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 4e7a72324a..d8d21dcc42 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 @@ -63,10 +63,6 @@ describe('recall bot api', () => { meeting_url: 'https://meet.google.com/abc-defg-hij', join_at: '2026-01-01T13:00:00.000Z', bot_name: 'Twenty Meeting Bot', - automatic_leave: { - waiting_room_timeout: 1200, - noone_joined_timeout: 1200, - }, recording_config: { video_mixed_mp4: {}, audio_mixed_mp3: {}, @@ -79,33 +75,6 @@ describe('recall bot api', () => { }); }); - it('carries the automatic leave config when rescheduling a bot', async () => { - const result = await rescheduleRecallBot({ - externalBotId: 'recall-bot-id', - meetingUrl: 'https://meet.google.com/abc-defg-hij', - joinAt: '2026-01-02T13:00:00.000Z', - metadata: { - twentyCallRecordingId: 'call-recording-id', - twentyCalendarEventId: 'calendar-event-id', - twentyRealMeetingKey: 'meeting-key', - }, - }); - - expect(result).toEqual({ ok: true, externalBotId: 'recall-bot-id' }); - expect(fetchMock).toHaveBeenCalledWith( - 'https://ap-northeast-1.recall.ai/api/v1/bot/recall-bot-id/', - expect.objectContaining({ method: 'PATCH' }), - ); - expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual( - expect.objectContaining({ - automatic_leave: { - waiting_room_timeout: 1200, - noone_joined_timeout: 1200, - }, - }), - ); - }); - it('fails when the create response does not include a bot id', async () => { fetchMock.mockResolvedValue({ ok: true, 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 d66a92aa9a..b22455fa73 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,4 +1,6 @@ -import { RECALL_BOT_AUTOMATIC_LEAVE } from 'src/logic-functions/constants/recall-bot-automatic-leave'; +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 { type RecallBotScheduleResult } from 'src/logic-functions/types/recall-bot-operation-result.type'; import { @@ -25,6 +27,8 @@ export const rescheduleRecallBot = async ({ return { ok: false, status: null, errorMessage: configResult.error }; } + const automaticLeave = getRecallBotAutomaticLeave(); + const result = await recallBotApiRequest({ config: configResult.config, path: `/bot/${externalBotId}/`, @@ -33,7 +37,7 @@ export const rescheduleRecallBot = async ({ meeting_url: meetingUrl, join_at: joinAt, bot_name: configResult.config.botName, - automatic_leave: RECALL_BOT_AUTOMATIC_LEAVE, + ...(isUndefined(automaticLeave) ? {} : { automatic_leave: automaticLeave }), recording_config: RECALL_BOT_RECORDING_CONFIG, 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 8e5f071e72..9fa8e79bbc 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,6 +1,6 @@ import { isUndefined } from '@sniptt/guards'; -import { RECALL_BOT_AUTOMATIC_LEAVE } from 'src/logic-functions/constants/recall-bot-automatic-leave'; +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 { type RecallBotMetadata } from 'src/logic-functions/types/recall-bot-metadata.type'; import { type RecallBotScheduleResult } from 'src/logic-functions/types/recall-bot-operation-result.type'; @@ -28,6 +28,8 @@ export const scheduleRecallBot = async ({ return { ok: false, status: null, errorMessage: configResult.error }; } + const automaticLeave = getRecallBotAutomaticLeave(); + const result = await recallBotApiRequest({ config: configResult.config, path: '/bot/', @@ -36,7 +38,7 @@ export const scheduleRecallBot = async ({ meeting_url: meetingUrl, join_at: joinAt, bot_name: configResult.config.botName, - automatic_leave: RECALL_BOT_AUTOMATIC_LEAVE, + ...(isUndefined(automaticLeave) ? {} : { automatic_leave: automaticLeave }), recording_config: RECALL_BOT_RECORDING_CONFIG, metadata, }, diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/reconcile-stale-bot-state.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/reconcile-stale-bot-state.ts index 769d2a4438..7215bbcb61 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/reconcile-stale-bot-state.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/reconcile-stale-bot-state.ts @@ -122,7 +122,7 @@ export default defineLogicFunction({ name: 'reconcile-stale-bot-state', description: 'Converges call recordings with Recall on a schedule: pulls stale bot statuses and overdue transcripts, finishes failed cancellations, schedules bots for recordings still missing one, and reaps unclaimed bots. Reads calendar events only to heal already-decided recordings, never to discover meetings.', - timeoutSeconds: 300, + timeoutSeconds: 250, handler: reconcileStaleBotStateHandler, cronTriggerSettings: { pattern: STALE_BOT_STATE_CRON_PATTERN, diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/utils/get-application-variable-value.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/utils/get-application-variable-value.util.ts index 7903574f4d..19a9c2c7cd 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/utils/get-application-variable-value.util.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/utils/get-application-variable-value.util.ts @@ -1,3 +1,3 @@ -// Application variables are injected into process.env on every execution. +// Application and server variables are injected into process.env on every execution. export const getApplicationVariableValue = (key: string): string | undefined => process.env[key];