From 4045f9852e20513186ab3ca80d88edfbd3b6b21a Mon Sep 17 00:00:00 2001
From: nitin <142569587+ehconitin@users.noreply.github.com>
Date: Tue, 30 Jun 2026 14:01:15 +0530
Subject: [PATCH] feat(call-recorder): use workspace logo for Recall bot image
(#22302)
workspace logo -
how bot appears -
---
.../public/call-recorder/package.json | 3 +-
.../call-recorder/src/application-config.ts | 22 +++
...round-app-variable-universal-identifier.ts | 2 +
...-logo-app-variable-universal-identifier.ts | 2 +
.../get-bot-image-background.test.ts | 65 +++++++++
...s-workspace-logo-bot-image-enabled.test.ts | 53 +++++++
...order-bot-image-background-env-var-name.ts | 2 +
...ecorder-use-workspace-logo-env-var-name.ts | 2 +
...ault-call-recorder-bot-image-background.ts | 1 +
...efault-call-recorder-use-workspace-logo.ts | 1 +
.../constants/get-bot-image-background.ts | 22 +++
.../is-workspace-logo-bot-image-enabled.ts | 29 ++++
.../constants/recall-bot-image-config.ts | 14 ++
.../data/get-workspace-logo.util.ts | 42 ++++++
.../__tests__/build-bot-image.util.test.ts | 94 ++++++++++++
...ll-bot-automatic-video-output.util.test.ts | 87 +++++++++++
.../domain/build-bot-image.util.ts | 138 ++++++++++++++++++
...-recall-bot-automatic-video-output.util.ts | 42 ++++++
.../flows/ensure-call-recorder.util.ts | 4 +
.../recall-api/schedule-recall-bot.util.ts | 6 +
.../recall-bot-automatic-video-output.type.ts | 9 ++
.../public/call-recorder/yarn.lock | 1 +
22 files changed, 640 insertions(+), 1 deletion(-)
create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/call-recorder-bot-image-background-app-variable-universal-identifier.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/call-recorder-use-workspace-logo-app-variable-universal-identifier.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/__tests__/get-bot-image-background.test.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/__tests__/is-workspace-logo-bot-image-enabled.test.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-bot-image-background-env-var-name.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-use-workspace-logo-env-var-name.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/default-call-recorder-bot-image-background.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/default-call-recorder-use-workspace-logo.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/get-bot-image-background.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/is-workspace-logo-bot-image-enabled.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/recall-bot-image-config.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/data/get-workspace-logo.util.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/build-bot-image.util.test.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/build-recall-bot-automatic-video-output.util.test.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/domain/build-bot-image.util.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/domain/build-recall-bot-automatic-video-output.util.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/types/recall-bot-automatic-video-output.type.ts
diff --git a/packages/twenty-apps/public/call-recorder/package.json b/packages/twenty-apps/public/call-recorder/package.json
index 72bcafd395..73c85c7f5a 100644
--- a/packages/twenty-apps/public/call-recorder/package.json
+++ b/packages/twenty-apps/public/call-recorder/package.json
@@ -22,7 +22,8 @@
"test:watch": "vitest"
},
"dependencies": {
- "@sniptt/guards": "^0.2.0"
+ "@sniptt/guards": "^0.2.0",
+ "sharp": "^0.34.5"
},
"devDependencies": {
"@emotion/react": "^11.14.0",
diff --git a/packages/twenty-apps/public/call-recorder/src/application-config.ts b/packages/twenty-apps/public/call-recorder/src/application-config.ts
index 55375a6b32..e83b8228c1 100644
--- a/packages/twenty-apps/public/call-recorder/src/application-config.ts
+++ b/packages/twenty-apps/public/call-recorder/src/application-config.ts
@@ -3,11 +3,14 @@ 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 { CALL_RECORDER_BOT_IMAGE_BACKGROUND_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recorder-bot-image-background-app-variable-universal-identifier';
import { CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recorder-everyone-left-timeout-seconds-app-variable-universal-identifier';
import { CALL_RECORDER_JOIN_EARLY_MINUTES_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recorder-join-early-minutes-app-variable-universal-identifier';
import { CALL_RECORDER_NAME_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recorder-name-app-variable-universal-identifier';
import { CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recorder-noone-joined-timeout-seconds-app-variable-universal-identifier';
+import { CALL_RECORDER_USE_WORKSPACE_LOGO_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recorder-use-workspace-logo-app-variable-universal-identifier';
import { CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recorder-waiting-room-timeout-seconds-app-variable-universal-identifier';
+import { CALL_RECORDER_BOT_IMAGE_BACKGROUND_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-bot-image-background-env-var-name';
import { CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS } from 'src/logic-functions/constants/call-recorder-everyone-left-timeout-seconds';
import { CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-everyone-left-timeout-seconds-env-var-name';
import { CALL_RECORDER_JOIN_EARLY_MINUTES_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-join-early-minutes-env-var-name';
@@ -15,11 +18,14 @@ import { CALL_RECORDER_NAME_ENV_VAR_NAME } from 'src/logic-functions/constants/c
import { CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS } from 'src/logic-functions/constants/call-recorder-noone-joined-timeout-seconds';
import { CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-noone-joined-timeout-seconds-env-var-name';
import { CALL_RECORDER_RECORDING_RETENTION_HOURS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-recording-retention-hours-env-var-name';
+import { CALL_RECORDER_USE_WORKSPACE_LOGO_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-use-workspace-logo-env-var-name';
import { CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS } from 'src/logic-functions/constants/call-recorder-waiting-room-timeout-seconds';
import { CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-waiting-room-timeout-seconds-env-var-name';
+import { DEFAULT_CALL_RECORDER_BOT_IMAGE_BACKGROUND } from 'src/logic-functions/constants/default-call-recorder-bot-image-background';
import { DEFAULT_CALL_RECORDER_JOIN_EARLY_MINUTES } from 'src/logic-functions/constants/default-call-recorder-join-early-minutes';
import { DEFAULT_CALL_RECORDER_NAME } from 'src/logic-functions/constants/default-call-recorder-name';
import { DEFAULT_CALL_RECORDER_RECORDING_RETENTION_HOURS } from 'src/logic-functions/constants/default-call-recorder-recording-retention-hours';
+import { DEFAULT_CALL_RECORDER_USE_WORKSPACE_LOGO } from 'src/logic-functions/constants/default-call-recorder-use-workspace-logo';
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_REGION_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-region-env-var-name';
@@ -71,6 +77,22 @@ export default defineApplication({
isSecret: false,
value: String(CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS),
},
+ [CALL_RECORDER_USE_WORKSPACE_LOGO_ENV_VAR_NAME]: {
+ universalIdentifier:
+ CALL_RECORDER_USE_WORKSPACE_LOGO_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
+ description:
+ 'Whether the bot displays the workspace logo on its camera tile while in a call. Set to false to disable.',
+ isSecret: false,
+ value: String(DEFAULT_CALL_RECORDER_USE_WORKSPACE_LOGO),
+ },
+ [CALL_RECORDER_BOT_IMAGE_BACKGROUND_ENV_VAR_NAME]: {
+ universalIdentifier:
+ CALL_RECORDER_BOT_IMAGE_BACKGROUND_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
+ description:
+ 'Hex color (e.g. #ffffff) drawn behind the workspace logo on the bot camera tile. Defaults to dark gray when unset or invalid.',
+ isSecret: false,
+ value: DEFAULT_CALL_RECORDER_BOT_IMAGE_BACKGROUND,
+ },
},
serverVariables: {
[RECALL_API_KEY_ENV_VAR_NAME]: {
diff --git a/packages/twenty-apps/public/call-recorder/src/constants/call-recorder-bot-image-background-app-variable-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/call-recorder-bot-image-background-app-variable-universal-identifier.ts
new file mode 100644
index 0000000000..12734e6903
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/constants/call-recorder-bot-image-background-app-variable-universal-identifier.ts
@@ -0,0 +1,2 @@
+export const CALL_RECORDER_BOT_IMAGE_BACKGROUND_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
+ 'fd0d9495-29ae-443e-84f9-4ec4d1f2d16a';
diff --git a/packages/twenty-apps/public/call-recorder/src/constants/call-recorder-use-workspace-logo-app-variable-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/call-recorder-use-workspace-logo-app-variable-universal-identifier.ts
new file mode 100644
index 0000000000..21036d8ac3
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/constants/call-recorder-use-workspace-logo-app-variable-universal-identifier.ts
@@ -0,0 +1,2 @@
+export const CALL_RECORDER_USE_WORKSPACE_LOGO_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
+ 'fc06a41c-d1f3-4c51-a64b-d71ed77374c1';
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/__tests__/get-bot-image-background.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/__tests__/get-bot-image-background.test.ts
new file mode 100644
index 0000000000..569a5cd882
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/__tests__/get-bot-image-background.test.ts
@@ -0,0 +1,65 @@
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { CALL_RECORDER_BOT_IMAGE_BACKGROUND_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-bot-image-background-env-var-name';
+import { DEFAULT_CALL_RECORDER_BOT_IMAGE_BACKGROUND } from 'src/logic-functions/constants/default-call-recorder-bot-image-background';
+import { getBotImageBackground } from 'src/logic-functions/constants/get-bot-image-background';
+
+const ORIGINAL_VALUE =
+ process.env[CALL_RECORDER_BOT_IMAGE_BACKGROUND_ENV_VAR_NAME];
+
+const restore = () => {
+ if (ORIGINAL_VALUE === undefined) {
+ delete process.env[CALL_RECORDER_BOT_IMAGE_BACKGROUND_ENV_VAR_NAME];
+
+ return;
+ }
+
+ process.env[CALL_RECORDER_BOT_IMAGE_BACKGROUND_ENV_VAR_NAME] = ORIGINAL_VALUE;
+};
+
+describe('getBotImageBackground', () => {
+ afterEach(() => {
+ restore();
+ });
+
+ it('returns the dark default when unset', () => {
+ delete process.env[CALL_RECORDER_BOT_IMAGE_BACKGROUND_ENV_VAR_NAME];
+
+ expect(getBotImageBackground()).toBe('#1d1d1d');
+ });
+
+ it('returns the default when set to an empty string', () => {
+ process.env[CALL_RECORDER_BOT_IMAGE_BACKGROUND_ENV_VAR_NAME] = '';
+
+ expect(getBotImageBackground()).toBe(
+ DEFAULT_CALL_RECORDER_BOT_IMAGE_BACKGROUND,
+ );
+ });
+
+ it.each(['#000', '#000000', '#1B1B1B'])(
+ 'accepts valid hex color %s',
+ (color) => {
+ process.env[CALL_RECORDER_BOT_IMAGE_BACKGROUND_ENV_VAR_NAME] = color;
+
+ expect(getBotImageBackground()).toBe(color);
+ },
+ );
+
+ it('trims surrounding whitespace from a valid hex color', () => {
+ process.env[CALL_RECORDER_BOT_IMAGE_BACKGROUND_ENV_VAR_NAME] =
+ ' #1B1B1B ';
+
+ expect(getBotImageBackground()).toBe('#1B1B1B');
+ });
+
+ it.each(['white', '000000', '#xyzxyz', '#12345'])(
+ 'falls back to the default for invalid value %s',
+ (value) => {
+ process.env[CALL_RECORDER_BOT_IMAGE_BACKGROUND_ENV_VAR_NAME] = value;
+
+ expect(getBotImageBackground()).toBe(
+ DEFAULT_CALL_RECORDER_BOT_IMAGE_BACKGROUND,
+ );
+ },
+ );
+});
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/__tests__/is-workspace-logo-bot-image-enabled.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/__tests__/is-workspace-logo-bot-image-enabled.test.ts
new file mode 100644
index 0000000000..c61014f5be
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/__tests__/is-workspace-logo-bot-image-enabled.test.ts
@@ -0,0 +1,53 @@
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { CALL_RECORDER_USE_WORKSPACE_LOGO_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-use-workspace-logo-env-var-name';
+import { isWorkspaceLogoBotImageEnabled } from 'src/logic-functions/constants/is-workspace-logo-bot-image-enabled';
+
+const ORIGINAL_VALUE =
+ process.env[CALL_RECORDER_USE_WORKSPACE_LOGO_ENV_VAR_NAME];
+
+const restore = () => {
+ if (ORIGINAL_VALUE === undefined) {
+ delete process.env[CALL_RECORDER_USE_WORKSPACE_LOGO_ENV_VAR_NAME];
+
+ return;
+ }
+
+ process.env[CALL_RECORDER_USE_WORKSPACE_LOGO_ENV_VAR_NAME] = ORIGINAL_VALUE;
+};
+
+describe('isWorkspaceLogoBotImageEnabled', () => {
+ afterEach(() => {
+ restore();
+ });
+
+ it('defaults to enabled when unset', () => {
+ delete process.env[CALL_RECORDER_USE_WORKSPACE_LOGO_ENV_VAR_NAME];
+
+ expect(isWorkspaceLogoBotImageEnabled()).toBe(true);
+ });
+
+ it.each(['false', '0', 'no', 'off', 'FALSE', ' Off '])(
+ 'is disabled for falsy value %s',
+ (value) => {
+ process.env[CALL_RECORDER_USE_WORKSPACE_LOGO_ENV_VAR_NAME] = value;
+
+ expect(isWorkspaceLogoBotImageEnabled()).toBe(false);
+ },
+ );
+
+ it.each(['true', '1', 'yes', 'on', 'TRUE'])(
+ 'is enabled for truthy value %s',
+ (value) => {
+ process.env[CALL_RECORDER_USE_WORKSPACE_LOGO_ENV_VAR_NAME] = value;
+
+ expect(isWorkspaceLogoBotImageEnabled()).toBe(true);
+ },
+ );
+
+ it('defaults to enabled for an unrecognized value', () => {
+ process.env[CALL_RECORDER_USE_WORKSPACE_LOGO_ENV_VAR_NAME] = 'maybe';
+
+ expect(isWorkspaceLogoBotImageEnabled()).toBe(true);
+ });
+});
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-bot-image-background-env-var-name.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-bot-image-background-env-var-name.ts
new file mode 100644
index 0000000000..e9b9c932b7
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-bot-image-background-env-var-name.ts
@@ -0,0 +1,2 @@
+export const CALL_RECORDER_BOT_IMAGE_BACKGROUND_ENV_VAR_NAME =
+ 'CALL_RECORDER_BOT_IMAGE_BACKGROUND';
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-use-workspace-logo-env-var-name.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-use-workspace-logo-env-var-name.ts
new file mode 100644
index 0000000000..03688f8f4c
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-use-workspace-logo-env-var-name.ts
@@ -0,0 +1,2 @@
+export const CALL_RECORDER_USE_WORKSPACE_LOGO_ENV_VAR_NAME =
+ 'CALL_RECORDER_USE_WORKSPACE_LOGO';
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/default-call-recorder-bot-image-background.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/default-call-recorder-bot-image-background.ts
new file mode 100644
index 0000000000..b17fbefbd7
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/default-call-recorder-bot-image-background.ts
@@ -0,0 +1 @@
+export const DEFAULT_CALL_RECORDER_BOT_IMAGE_BACKGROUND = '#1d1d1d';
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/default-call-recorder-use-workspace-logo.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/default-call-recorder-use-workspace-logo.ts
new file mode 100644
index 0000000000..e03a6af999
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/default-call-recorder-use-workspace-logo.ts
@@ -0,0 +1 @@
+export const DEFAULT_CALL_RECORDER_USE_WORKSPACE_LOGO = true;
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/get-bot-image-background.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/get-bot-image-background.ts
new file mode 100644
index 0000000000..cca17df419
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/get-bot-image-background.ts
@@ -0,0 +1,22 @@
+import { CALL_RECORDER_BOT_IMAGE_BACKGROUND_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-bot-image-background-env-var-name';
+import { DEFAULT_CALL_RECORDER_BOT_IMAGE_BACKGROUND } from 'src/logic-functions/constants/default-call-recorder-bot-image-background';
+import { getApplicationVariableValue } from 'src/logic-functions/utils/get-application-variable-value.util';
+import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
+
+const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
+
+export const getBotImageBackground = (): string => {
+ const rawValue = getApplicationVariableValue(
+ CALL_RECORDER_BOT_IMAGE_BACKGROUND_ENV_VAR_NAME,
+ );
+
+ if (!isNonEmptyString(rawValue)) {
+ return DEFAULT_CALL_RECORDER_BOT_IMAGE_BACKGROUND;
+ }
+
+ const normalizedValue = rawValue.trim();
+
+ return HEX_COLOR_PATTERN.test(normalizedValue)
+ ? normalizedValue
+ : DEFAULT_CALL_RECORDER_BOT_IMAGE_BACKGROUND;
+};
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/is-workspace-logo-bot-image-enabled.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/is-workspace-logo-bot-image-enabled.ts
new file mode 100644
index 0000000000..7aff87a868
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/is-workspace-logo-bot-image-enabled.ts
@@ -0,0 +1,29 @@
+import { CALL_RECORDER_USE_WORKSPACE_LOGO_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-use-workspace-logo-env-var-name';
+import { DEFAULT_CALL_RECORDER_USE_WORKSPACE_LOGO } from 'src/logic-functions/constants/default-call-recorder-use-workspace-logo';
+import { getApplicationVariableValue } from 'src/logic-functions/utils/get-application-variable-value.util';
+import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
+
+const TRUTHY_VALUES = new Set(['true', '1', 'yes', 'on']);
+const FALSY_VALUES = new Set(['false', '0', 'no', 'off']);
+
+export const isWorkspaceLogoBotImageEnabled = (): boolean => {
+ const rawValue = getApplicationVariableValue(
+ CALL_RECORDER_USE_WORKSPACE_LOGO_ENV_VAR_NAME,
+ );
+
+ if (!isNonEmptyString(rawValue)) {
+ return DEFAULT_CALL_RECORDER_USE_WORKSPACE_LOGO;
+ }
+
+ const normalizedValue = rawValue.trim().toLowerCase();
+
+ if (TRUTHY_VALUES.has(normalizedValue)) {
+ return true;
+ }
+
+ if (FALSY_VALUES.has(normalizedValue)) {
+ return false;
+ }
+
+ return DEFAULT_CALL_RECORDER_USE_WORKSPACE_LOGO;
+};
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/recall-bot-image-config.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/recall-bot-image-config.ts
new file mode 100644
index 0000000000..81a74534de
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/recall-bot-image-config.ts
@@ -0,0 +1,14 @@
+// Meeting platforms cap the bot's camera output at 1280x720; the logo box stays
+// within a centered safezone so it survives adaptive participant tile cropping.
+export const RECALL_BOT_IMAGE_WIDTH = 1280;
+export const RECALL_BOT_IMAGE_HEIGHT = 720;
+export const RECALL_BOT_IMAGE_LOGO_MAX_WIDTH = 760;
+export const RECALL_BOT_IMAGE_LOGO_MAX_HEIGHT = 420;
+export const RECALL_BOT_IMAGE_JPEG_QUALITY = 90;
+export const RECALL_BOT_IMAGE_MIN_JPEG_QUALITY = 40;
+// Recall rejects images above 1.3MB; stay just under it.
+export const RECALL_BOT_IMAGE_MAX_BYTES = 1_300_000;
+export const RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_WIDTH = 132;
+export const RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_HEIGHT = 48;
+export const RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_INSET = 56;
+export const RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_DOT_DIAMETER = 12;
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/get-workspace-logo.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/get-workspace-logo.util.ts
new file mode 100644
index 0000000000..da6b1a4df4
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/get-workspace-logo.util.ts
@@ -0,0 +1,42 @@
+import { MetadataApiClient } from 'twenty-client-sdk/metadata';
+
+import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
+
+const LOGO_DOWNLOAD_TIMEOUT_MS = 30_000;
+
+// `currentWorkspace.logo` resolves to a signed, expiring file URL that we then download.
+export const getWorkspaceLogo = async (): Promise => {
+ try {
+ const metadataClient = new MetadataApiClient();
+
+ const queryResult = await metadataClient.query({
+ currentWorkspace: { logo: true },
+ });
+
+ const logoUrl = queryResult.currentWorkspace?.logo;
+
+ if (!isNonEmptyString(logoUrl)) {
+ return undefined;
+ }
+
+ const response = await fetch(logoUrl, {
+ signal: AbortSignal.timeout(LOGO_DOWNLOAD_TIMEOUT_MS),
+ });
+
+ if (!response.ok) {
+ console.warn(
+ `[call-recorder] failed to download workspace logo: status ${response.status}`,
+ );
+
+ return undefined;
+ }
+
+ return Buffer.from(await response.arrayBuffer());
+ } catch (error) {
+ console.warn(
+ `[call-recorder] failed to read workspace logo: ${error instanceof Error ? error.message : String(error)}`,
+ );
+
+ return undefined;
+ }
+};
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/build-bot-image.util.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/build-bot-image.util.test.ts
new file mode 100644
index 0000000000..0af066ccf8
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/build-bot-image.util.test.ts
@@ -0,0 +1,94 @@
+import sharp from 'sharp';
+import { describe, expect, it } from 'vitest';
+
+import { buildBotImage } from 'src/logic-functions/domain/build-bot-image.util';
+import {
+ RECALL_BOT_IMAGE_HEIGHT,
+ RECALL_BOT_IMAGE_MAX_BYTES,
+ RECALL_BOT_IMAGE_WIDTH,
+} from 'src/logic-functions/constants/recall-bot-image-config';
+
+const createLogo = (format: 'png' | 'gif' | 'jpeg'): Promise => {
+ const image = sharp({
+ create: {
+ width: 200,
+ height: 200,
+ channels: 3,
+ background: '#3b82f6',
+ },
+ });
+
+ if (format === 'png') {
+ return image.png().toBuffer();
+ }
+
+ if (format === 'gif') {
+ return image.gif().toBuffer();
+ }
+
+ return image.jpeg().toBuffer();
+};
+
+describe('buildBotImage', () => {
+ it.each(['png', 'gif', 'jpeg'] as const)(
+ 'produces a 1280x720 JPEG under the size cap from a %s logo',
+ async (format) => {
+ const logoBuffer = await createLogo(format);
+
+ const base64Jpeg = await buildBotImage({
+ logoBuffer,
+ background: '#ffffff',
+ });
+
+ expect(base64Jpeg).toBeDefined();
+
+ const jpegBuffer = Buffer.from(base64Jpeg as string, 'base64');
+
+ // JPEG SOI marker.
+ expect(jpegBuffer[0]).toBe(0xff);
+ expect(jpegBuffer[1]).toBe(0xd8);
+ expect(jpegBuffer.byteLength).toBeLessThanOrEqual(
+ RECALL_BOT_IMAGE_MAX_BYTES,
+ );
+
+ const metadata = await sharp(jpegBuffer).metadata();
+
+ expect(metadata.width).toBe(RECALL_BOT_IMAGE_WIDTH);
+ expect(metadata.height).toBe(RECALL_BOT_IMAGE_HEIGHT);
+ expect(metadata.format).toBe('jpeg');
+ },
+ );
+
+ it('produces a valid 1280x720 JPEG with the recording badge', async () => {
+ const logoBuffer = await createLogo('png');
+
+ const base64Jpeg = await buildBotImage({
+ logoBuffer,
+ background: '#ffffff',
+ withRecordingStatusBadge: true,
+ });
+
+ expect(base64Jpeg).toBeDefined();
+
+ const jpegBuffer = Buffer.from(base64Jpeg as string, 'base64');
+
+ expect(jpegBuffer.byteLength).toBeLessThanOrEqual(
+ RECALL_BOT_IMAGE_MAX_BYTES,
+ );
+
+ const metadata = await sharp(jpegBuffer).metadata();
+
+ expect(metadata.width).toBe(RECALL_BOT_IMAGE_WIDTH);
+ expect(metadata.height).toBe(RECALL_BOT_IMAGE_HEIGHT);
+ expect(metadata.format).toBe('jpeg');
+ });
+
+ it('returns undefined when the source cannot be decoded', async () => {
+ const result = await buildBotImage({
+ logoBuffer: Buffer.from('not-an-image'),
+ background: '#ffffff',
+ });
+
+ expect(result).toBeUndefined();
+ });
+});
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/build-recall-bot-automatic-video-output.util.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/build-recall-bot-automatic-video-output.util.test.ts
new file mode 100644
index 0000000000..1c99c77799
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/build-recall-bot-automatic-video-output.util.test.ts
@@ -0,0 +1,87 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { buildRecallBotAutomaticVideoOutput } from 'src/logic-functions/domain/build-recall-bot-automatic-video-output.util';
+
+const isEnabledMock = vi.hoisted(() => vi.fn());
+const getBackgroundMock = vi.hoisted(() => vi.fn());
+const getWorkspaceLogoMock = vi.hoisted(() => vi.fn());
+const buildBotImageMock = vi.hoisted(() => vi.fn());
+
+vi.mock(
+ 'src/logic-functions/constants/is-workspace-logo-bot-image-enabled',
+ () => ({ isWorkspaceLogoBotImageEnabled: isEnabledMock }),
+);
+
+vi.mock('src/logic-functions/constants/get-bot-image-background', () => ({
+ getBotImageBackground: getBackgroundMock,
+}));
+
+vi.mock('src/logic-functions/data/get-workspace-logo.util', () => ({
+ getWorkspaceLogo: getWorkspaceLogoMock,
+}));
+
+vi.mock('src/logic-functions/domain/build-bot-image.util', () => ({
+ buildBotImage: buildBotImageMock,
+}));
+
+describe('buildRecallBotAutomaticVideoOutput', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ isEnabledMock.mockReturnValue(true);
+ getBackgroundMock.mockReturnValue('#ffffff');
+ getWorkspaceLogoMock.mockResolvedValue(Buffer.from('logo'));
+ buildBotImageMock.mockImplementation(({ withRecordingStatusBadge }) =>
+ Promise.resolve(
+ withRecordingStatusBadge ? 'RECORDING_JPEG' : 'PLAIN_JPEG',
+ ),
+ );
+ });
+
+ it('badges the recording state and leaves the not-recording state plain', async () => {
+ const result = await buildRecallBotAutomaticVideoOutput();
+
+ expect(result).toEqual({
+ in_call_recording: { kind: 'jpeg', b64_data: 'RECORDING_JPEG' },
+ in_call_not_recording: { kind: 'jpeg', b64_data: 'PLAIN_JPEG' },
+ });
+ });
+
+ it('falls back to the plain image when the badged variant fails', async () => {
+ buildBotImageMock.mockImplementation(({ withRecordingStatusBadge }) =>
+ Promise.resolve(withRecordingStatusBadge ? undefined : 'PLAIN_JPEG'),
+ );
+
+ const result = await buildRecallBotAutomaticVideoOutput();
+
+ expect(result).toEqual({
+ in_call_recording: { kind: 'jpeg', b64_data: 'PLAIN_JPEG' },
+ in_call_not_recording: { kind: 'jpeg', b64_data: 'PLAIN_JPEG' },
+ });
+ });
+
+ it('returns undefined when the feature is disabled', async () => {
+ isEnabledMock.mockReturnValue(false);
+
+ const result = await buildRecallBotAutomaticVideoOutput();
+
+ expect(result).toBeUndefined();
+ expect(getWorkspaceLogoMock).not.toHaveBeenCalled();
+ });
+
+ it('returns undefined when no workspace logo is set', async () => {
+ getWorkspaceLogoMock.mockResolvedValue(undefined);
+
+ const result = await buildRecallBotAutomaticVideoOutput();
+
+ expect(result).toBeUndefined();
+ expect(buildBotImageMock).not.toHaveBeenCalled();
+ });
+
+ it('returns undefined when the image cannot be built', async () => {
+ buildBotImageMock.mockResolvedValue(undefined);
+
+ const result = await buildRecallBotAutomaticVideoOutput();
+
+ expect(result).toBeUndefined();
+ });
+});
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/build-bot-image.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/build-bot-image.util.ts
new file mode 100644
index 0000000000..396e2f3a2d
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/build-bot-image.util.ts
@@ -0,0 +1,138 @@
+import type { OverlayOptions } from 'sharp';
+
+import {
+ RECALL_BOT_IMAGE_HEIGHT,
+ RECALL_BOT_IMAGE_JPEG_QUALITY,
+ RECALL_BOT_IMAGE_LOGO_MAX_HEIGHT,
+ RECALL_BOT_IMAGE_LOGO_MAX_WIDTH,
+ RECALL_BOT_IMAGE_MAX_BYTES,
+ RECALL_BOT_IMAGE_MIN_JPEG_QUALITY,
+ RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_DOT_DIAMETER,
+ RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_HEIGHT,
+ RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_INSET,
+ RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_WIDTH,
+ RECALL_BOT_IMAGE_WIDTH,
+} from 'src/logic-functions/constants/recall-bot-image-config';
+
+const JPEG_QUALITY_STEP = 10;
+const RECORDING_STATUS_BADGE_BACKGROUND = '#feebec';
+const RECORDING_STATUS_BADGE_COLOR = '#ce2c31';
+const RECORDING_STATUS_BADGE_TEXT = 'REC';
+
+type SharpFactory = typeof import('sharp');
+
+export const buildBotImage = async ({
+ logoBuffer,
+ background,
+ withRecordingStatusBadge = false,
+}: {
+ logoBuffer: Buffer;
+ background: string;
+ withRecordingStatusBadge?: boolean;
+}): Promise => {
+ try {
+ // Loaded on use so the native module isn't required when the manifest build
+ // merely imports this module.
+ const sharp = await loadSharp();
+
+ // Animated sources collapse to their first frame, which is the intended tile.
+ const logo = await sharp(logoBuffer)
+ .resize(
+ RECALL_BOT_IMAGE_LOGO_MAX_WIDTH,
+ RECALL_BOT_IMAGE_LOGO_MAX_HEIGHT,
+ { fit: 'inside', withoutEnlargement: true },
+ )
+ .png()
+ .toBuffer();
+
+ const composites: OverlayOptions[] = [{ input: logo, gravity: 'center' }];
+
+ if (withRecordingStatusBadge) {
+ composites.push({
+ input: await buildRecordingStatusBadge(sharp),
+ top: RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_INSET,
+ left: RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_INSET,
+ });
+ }
+
+ let quality = RECALL_BOT_IMAGE_JPEG_QUALITY;
+ let jpeg = await composeJpeg({ sharp, composites, background, quality });
+
+ while (
+ jpeg.byteLength > RECALL_BOT_IMAGE_MAX_BYTES &&
+ quality - JPEG_QUALITY_STEP >= RECALL_BOT_IMAGE_MIN_JPEG_QUALITY
+ ) {
+ quality -= JPEG_QUALITY_STEP;
+ jpeg = await composeJpeg({ sharp, composites, background, quality });
+ }
+
+ if (jpeg.byteLength > RECALL_BOT_IMAGE_MAX_BYTES) {
+ console.warn(
+ '[call-recorder] workspace logo could not be encoded under the Recall image size limit',
+ );
+
+ return undefined;
+ }
+
+ return jpeg.toString('base64');
+ } catch (error) {
+ console.warn(
+ `[call-recorder] failed to build bot image from workspace logo: ${error instanceof Error ? error.message : String(error)}`,
+ );
+
+ return undefined;
+ }
+};
+
+const composeJpeg = ({
+ sharp,
+ composites,
+ background,
+ quality,
+}: {
+ sharp: SharpFactory;
+ composites: OverlayOptions[];
+ background: string;
+ quality: number;
+}): Promise =>
+ sharp({
+ create: {
+ width: RECALL_BOT_IMAGE_WIDTH,
+ height: RECALL_BOT_IMAGE_HEIGHT,
+ channels: 3,
+ background,
+ },
+ })
+ .composite(composites)
+ .jpeg({ quality, mozjpeg: true })
+ .toBuffer();
+
+const buildRecordingStatusBadge = (
+ sharp: SharpFactory,
+): Promise => {
+ const badgeCornerRadius =
+ RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_HEIGHT / 2;
+ const recordingDotCenter =
+ RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_HEIGHT / 2;
+ const recordingDotRadius =
+ RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_DOT_DIAMETER / 2;
+ const recordingStatusTextBaseline =
+ RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_HEIGHT / 2 + 7;
+
+ const svg = [
+ `',
+ ].join('');
+
+ return sharp(Buffer.from(svg)).png().toBuffer();
+};
+
+const loadSharp = async (): Promise => {
+ const sharpModule = await import('sharp');
+
+ return ((sharpModule.default as unknown as { default?: SharpFactory })
+ .default ?? sharpModule.default) as SharpFactory;
+};
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/build-recall-bot-automatic-video-output.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/build-recall-bot-automatic-video-output.util.ts
new file mode 100644
index 0000000000..3826e1a636
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/build-recall-bot-automatic-video-output.util.ts
@@ -0,0 +1,42 @@
+import { isUndefined } from '@sniptt/guards';
+
+import { getBotImageBackground } from 'src/logic-functions/constants/get-bot-image-background';
+import { isWorkspaceLogoBotImageEnabled } from 'src/logic-functions/constants/is-workspace-logo-bot-image-enabled';
+import { getWorkspaceLogo } from 'src/logic-functions/data/get-workspace-logo.util';
+import { buildBotImage } from 'src/logic-functions/domain/build-bot-image.util';
+import { type RecallBotAutomaticVideoOutput } from 'src/logic-functions/types/recall-bot-automatic-video-output.type';
+
+export const buildRecallBotAutomaticVideoOutput = async (): Promise<
+ RecallBotAutomaticVideoOutput | undefined
+> => {
+ if (!isWorkspaceLogoBotImageEnabled()) {
+ return undefined;
+ }
+
+ const logoBuffer = await getWorkspaceLogo();
+
+ if (isUndefined(logoBuffer)) {
+ return undefined;
+ }
+
+ const background = getBotImageBackground();
+
+ const notRecordingImage = await buildBotImage({ logoBuffer, background });
+
+ if (isUndefined(notRecordingImage)) {
+ return undefined;
+ }
+
+ const recordingImage =
+ (await buildBotImage({
+ logoBuffer,
+ background,
+ withRecordingStatusBadge: true,
+ })) ??
+ notRecordingImage;
+
+ return {
+ in_call_recording: { kind: 'jpeg', b64_data: recordingImage },
+ in_call_not_recording: { kind: 'jpeg', b64_data: notRecordingImage },
+ };
+};
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/ensure-call-recorder.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/ensure-call-recorder.util.ts
index 522e63acb3..459f9d3d46 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/ensure-call-recorder.util.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/ensure-call-recorder.util.ts
@@ -3,6 +3,7 @@ import { type 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 { buildRecallBotAutomaticVideoOutput } from 'src/logic-functions/domain/build-recall-bot-automatic-video-output.util';
import { buildRecallRoutingMetadata } from 'src/logic-functions/domain/build-recall-routing-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';
@@ -47,6 +48,8 @@ export const ensureCallRecorder = async (
return false;
}
+ const automaticVideoOutput = await buildRecallBotAutomaticVideoOutput();
+
const scheduleResult = await scheduleRecallBot({
meetingUrl,
joinAt,
@@ -54,6 +57,7 @@ export const ensureCallRecorder = async (
callRecordingId: callRecording.id,
workspaceId,
}),
+ automaticVideoOutput,
});
if (!scheduleResult.ok) {
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/schedule-recall-bot.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/schedule-recall-bot.util.ts
index 8883e54359..2fd7971b12 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/schedule-recall-bot.util.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/schedule-recall-bot.util.ts
@@ -2,6 +2,7 @@ import { isUndefined } from '@sniptt/guards';
import { getRecallBotAutomaticLeave } from 'src/logic-functions/constants/recall-bot-automatic-leave';
import { getRecallBotRecordingConfig } from 'src/logic-functions/constants/recall-bot-recording-config';
+import { type RecallBotAutomaticVideoOutput } from 'src/logic-functions/types/recall-bot-automatic-video-output.type';
import { type RecallRoutingMetadata } from 'src/logic-functions/types/recall-routing-metadata.type';
import { type RecallBotScheduleResult } from 'src/logic-functions/types/recall-bot-operation-result.type';
import {
@@ -15,12 +16,14 @@ export type ScheduleRecallBotArgs = {
meetingUrl: string;
joinAt: string;
metadata: RecallRoutingMetadata;
+ automaticVideoOutput?: RecallBotAutomaticVideoOutput;
};
export const scheduleRecallBot = async ({
meetingUrl,
joinAt,
metadata,
+ automaticVideoOutput,
}: ScheduleRecallBotArgs): Promise => {
const configResult = getRecallApiConfig();
@@ -41,6 +44,9 @@ export const scheduleRecallBot = async ({
...(isUndefined(automaticLeave)
? {}
: { automatic_leave: automaticLeave }),
+ ...(isUndefined(automaticVideoOutput)
+ ? {}
+ : { automatic_video_output: automaticVideoOutput }),
recording_config: getRecallBotRecordingConfig(),
metadata,
},
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/types/recall-bot-automatic-video-output.type.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/types/recall-bot-automatic-video-output.type.ts
new file mode 100644
index 0000000000..34305fc789
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/types/recall-bot-automatic-video-output.type.ts
@@ -0,0 +1,9 @@
+type RecallBotVideoFrame = {
+ kind: 'jpeg';
+ b64_data: string;
+};
+
+export type RecallBotAutomaticVideoOutput = {
+ in_call_recording: RecallBotVideoFrame;
+ in_call_not_recording: RecallBotVideoFrame;
+};
diff --git a/packages/twenty-apps/public/call-recorder/yarn.lock b/packages/twenty-apps/public/call-recorder/yarn.lock
index e164155f09..5a23521880 100644
--- a/packages/twenty-apps/public/call-recorder/yarn.lock
+++ b/packages/twenty-apps/public/call-recorder/yarn.lock
@@ -1238,6 +1238,7 @@ __metadata:
oxlint: "npm:^0.16.0"
react: "npm:^19.0.0"
react-dom: "npm:^19.0.0"
+ sharp: "npm:^0.34.5"
twenty-client-sdk: "npm:^2.16.0"
twenty-sdk: "npm:^2.16.0"
typescript: "npm:^5.9.3"