From 06cefb1dac629bf8e04057f8f6036313bc7d27b2 Mon Sep 17 00:00:00 2001
From: nitin <142569587+ehconitin@users.noreply.github.com>
Date: Thu, 18 Jun 2026 18:28:25 +0530
Subject: [PATCH] Configure Recall bot server variables (#21774)
## What changed
- Added server variables for Recall bot leave behavior
- Added `RECALL_BOT_JOIN_EARLY_MINUTES` so the bot can join slightly
before meeting start
- Defaults stay aligned with Recall where applicable
- Kept descriptions more human-friendly in app config + README
---
.../internal/twenty-meeting-bot/README.md | 14 +++-
.../src/application-config.ts | 44 +++++++++++
...conds-app-variable-universal-identifier.ts | 2 +
...nutes-app-variable-universal-identifier.ts | 2 +
...conds-app-variable-universal-identifier.ts | 2 +
...conds-app-variable-universal-identifier.ts | 2 +
.../default-recall-bot-join-early-minutes.ts | 1 +
.../constants/milliseconds-per-minute.ts | 1 +
.../constants/recall-bot-automatic-leave.ts | 76 +++++++++++++++++--
...ryone-left-timeout-seconds-env-var-name.ts | 2 +
...ecall-bot-everyone-left-timeout-seconds.ts | 1 +
...all-bot-join-early-minutes-env-var-name.ts | 2 +
...one-joined-timeout-seconds-env-var-name.ts | 2 +
...iting-room-timeout-seconds-env-var-name.ts | 2 +
.../constants/stale-bot-state-cron-pattern.ts | 2 +-
.../compute-call-recording-charge.util.ts | 2 +-
.../domain/compute-recall-bot-join-at.util.ts | 34 +++++++++
.../__tests__/reconcile-meeting-bot.test.ts | 8 +-
.../flows/ensure-meeting-bot.util.ts | 7 +-
.../reschedule-call-recording-bot.util.ts | 7 +-
.../__tests__/recall-bot-api.test.ts | 31 --------
.../recall-api/reschedule-recall-bot.util.ts | 8 +-
.../recall-api/schedule-recall-bot.util.ts | 6 +-
.../reconcile-stale-bot-state.ts | 2 +-
.../get-application-variable-value.util.ts | 2 +-
25 files changed, 210 insertions(+), 52 deletions(-)
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/constants/recall-bot-everyone-left-timeout-seconds-app-variable-universal-identifier.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/constants/recall-bot-join-early-minutes-app-variable-universal-identifier.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/constants/recall-bot-noone-joined-timeout-seconds-app-variable-universal-identifier.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/constants/recall-bot-waiting-room-timeout-seconds-app-variable-universal-identifier.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/default-recall-bot-join-early-minutes.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/milliseconds-per-minute.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-everyone-left-timeout-seconds-env-var-name.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-everyone-left-timeout-seconds.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-join-early-minutes-env-var-name.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-noone-joined-timeout-seconds-env-var-name.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-waiting-room-timeout-seconds-env-var-name.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/compute-recall-bot-join-at.util.ts
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];