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 - CleanShot 2026-06-29 at 20 43 11 how bot appears - CleanShot 2026-06-29 at 20 47 01 Review in cubic --- .../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 = [ + ``, + ``, + ``, + `${RECORDING_STATUS_BADGE_TEXT}`, + '', + ].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"