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:
@@ -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]: {
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const CALL_RECORDER_BOT_IMAGE_BACKGROUND_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
|
||||
'fd0d9495-29ae-443e-84f9-4ec4d1f2d16a';
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const CALL_RECORDER_USE_WORKSPACE_LOGO_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
|
||||
'fc06a41c-d1f3-4c51-a64b-d71ed77374c1';
|
||||
+65
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
+53
@@ -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);
|
||||
});
|
||||
});
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const CALL_RECORDER_BOT_IMAGE_BACKGROUND_ENV_VAR_NAME =
|
||||
'CALL_RECORDER_BOT_IMAGE_BACKGROUND';
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const CALL_RECORDER_USE_WORKSPACE_LOGO_ENV_VAR_NAME =
|
||||
'CALL_RECORDER_USE_WORKSPACE_LOGO';
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const DEFAULT_CALL_RECORDER_BOT_IMAGE_BACKGROUND = '#1d1d1d';
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const DEFAULT_CALL_RECORDER_USE_WORKSPACE_LOGO = true;
|
||||
+22
@@ -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;
|
||||
};
|
||||
+29
@@ -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;
|
||||
};
|
||||
+14
@@ -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;
|
||||
+42
@@ -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;
|
||||
}
|
||||
};
|
||||
+94
@@ -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();
|
||||
});
|
||||
});
|
||||
+87
@@ -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();
|
||||
});
|
||||
});
|
||||
+138
@@ -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;
|
||||
};
|
||||
+42
@@ -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 },
|
||||
};
|
||||
};
|
||||
+4
@@ -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) {
|
||||
|
||||
+6
@@ -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,
|
||||
},
|
||||
|
||||
+9
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user