feat(call-recorder): use workspace logo for Recall bot image (#22302)

workspace logo - 

<img width="433" height="465" alt="CleanShot 2026-06-29 at 20 43 11"
src="https://github.com/user-attachments/assets/946782a5-7bab-461b-a613-ce056fec8bbc"
/>


how bot appears - 

<img width="2560" height="1315" alt="CleanShot 2026-06-29 at 20 47 01"
src="https://github.com/user-attachments/assets/91daa29c-83ef-4ac1-9d7c-ffa8bcf08135"
/>



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22302?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
nitin
2026-06-30 14:01:15 +05:30
committed by GitHub
parent 460b203b38
commit 4045f9852e
22 changed files with 640 additions and 1 deletions
@@ -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",
@@ -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]: {
@@ -0,0 +1,2 @@
export const CALL_RECORDER_BOT_IMAGE_BACKGROUND_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'fd0d9495-29ae-443e-84f9-4ec4d1f2d16a';
@@ -0,0 +1,2 @@
export const CALL_RECORDER_USE_WORKSPACE_LOGO_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'fc06a41c-d1f3-4c51-a64b-d71ed77374c1';
@@ -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,
);
},
);
});
@@ -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);
});
});
@@ -0,0 +1,2 @@
export const CALL_RECORDER_BOT_IMAGE_BACKGROUND_ENV_VAR_NAME =
'CALL_RECORDER_BOT_IMAGE_BACKGROUND';
@@ -0,0 +1,2 @@
export const CALL_RECORDER_USE_WORKSPACE_LOGO_ENV_VAR_NAME =
'CALL_RECORDER_USE_WORKSPACE_LOGO';
@@ -0,0 +1 @@
export const DEFAULT_CALL_RECORDER_BOT_IMAGE_BACKGROUND = '#1d1d1d';
@@ -0,0 +1 @@
export const DEFAULT_CALL_RECORDER_USE_WORKSPACE_LOGO = true;
@@ -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;
};
@@ -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;
};
@@ -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;
@@ -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<Buffer | undefined> => {
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;
}
};
@@ -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<Buffer> => {
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();
});
});
@@ -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();
});
});
@@ -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<string | undefined> => {
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<Buffer> =>
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<Buffer> => {
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 = [
`<svg xmlns="http://www.w3.org/2000/svg" width="${RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_WIDTH}" height="${RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_HEIGHT}">`,
`<rect width="${RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_WIDTH}" height="${RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_HEIGHT}" rx="${badgeCornerRadius}" fill="${RECORDING_STATUS_BADGE_BACKGROUND}" fill-opacity="0.96" />`,
`<circle cx="${recordingDotCenter}" cy="${recordingDotCenter}" r="${recordingDotRadius}" fill="${RECORDING_STATUS_BADGE_COLOR}" />`,
`<text x="44" y="${recordingStatusTextBaseline}" fill="${RECORDING_STATUS_BADGE_COLOR}" font-family="Arial, Helvetica, sans-serif" font-size="20" font-weight="600" letter-spacing="0">${RECORDING_STATUS_BADGE_TEXT}</text>`,
'</svg>',
].join('');
return sharp(Buffer.from(svg)).png().toBuffer();
};
const loadSharp = async (): Promise<SharpFactory> => {
const sharpModule = await import('sharp');
return ((sharpModule.default as unknown as { default?: SharpFactory })
.default ?? sharpModule.default) as SharpFactory;
};
@@ -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 },
};
};
@@ -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) {
@@ -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<RecallBotScheduleResult> => {
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,
},
@@ -0,0 +1,9 @@
type RecallBotVideoFrame = {
kind: 'jpeg';
b64_data: string;
};
export type RecallBotAutomaticVideoOutput = {
in_call_recording: RecallBotVideoFrame;
in_call_not_recording: RecallBotVideoFrame;
};
@@ -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"