Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s
499 lines
17 KiB
TypeScript
499 lines
17 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { shell, systemPreferences } from 'electron';
|
|
import RecallAiSdk, {
|
|
type RecallAiSdkWindow,
|
|
type EventTypeToPayloadMap,
|
|
} from '@recallai/desktop-sdk';
|
|
import { type RecordingUpload } from '../../shared/types/RecordingUpload';
|
|
import { type RecordingConfiguration } from '../../shared/types/RecordingConfiguration';
|
|
import { type RecordingResult } from '../../shared/types/RecordingResult';
|
|
import { type CompanionState } from '../../shared/types/CompanionState';
|
|
import { matchMeeting } from '../../shared/utils/matchMeeting';
|
|
import { type TwentyClient } from './TwentyClient';
|
|
|
|
const RECALL_REGIONS = new Set([
|
|
'https://eu-central-1.recall.ai',
|
|
'https://us-east-1.recall.ai',
|
|
'https://us-west-2.recall.ai',
|
|
'https://ap-northeast-1.recall.ai',
|
|
]);
|
|
|
|
type RecordingEvents = {
|
|
emit: () => void;
|
|
openApp: () => void;
|
|
notify: (title: string, body: string) => void;
|
|
fail: (error: unknown) => void;
|
|
refresh: () => Promise<void>;
|
|
canRecord: () => boolean;
|
|
isStopping: () => boolean;
|
|
};
|
|
|
|
export class RecordingController {
|
|
private sdkRegion: string | null = null;
|
|
private sdkInitialization: Promise<void> | null = null;
|
|
private autoHandledWindows = new Set<string>();
|
|
private interruptedByNetwork = false;
|
|
private networkConnected = true;
|
|
private recordingPause: Promise<void> | null = null;
|
|
private recordingStop: Promise<void> | null = null;
|
|
private recordingStart: Promise<void> | null = null;
|
|
private unsubscribeRecording: (() => void)[] = [];
|
|
|
|
constructor(
|
|
private state: CompanionState,
|
|
private client: TwentyClient,
|
|
private events: RecordingEvents,
|
|
) {}
|
|
|
|
get isRecording(): boolean {
|
|
return !!this.state.activeRecording || !!this.recordingStart;
|
|
}
|
|
|
|
get listening(): boolean {
|
|
return this.unsubscribeRecording.length > 0;
|
|
}
|
|
|
|
async resetSdk(): Promise<void> {
|
|
await this.sdkInitialization;
|
|
await this.stopSdk();
|
|
}
|
|
|
|
resetWorkspace(): void {
|
|
this.autoHandledWindows.clear();
|
|
}
|
|
|
|
async shutdown(): Promise<void> {
|
|
await this.recordingStart?.catch(() => undefined);
|
|
await this.sdkInitialization?.catch(() => undefined);
|
|
await this.stopRecording();
|
|
await this.stopSdk();
|
|
}
|
|
|
|
dispose(): void {
|
|
this.unsubscribeRecording.forEach((unsubscribe) => unsubscribe());
|
|
this.unsubscribeRecording = [];
|
|
}
|
|
|
|
async pause(pausing: boolean): Promise<void> {
|
|
if (this.recordingPause) return;
|
|
this.recordingPause = this.changeRecordingPause(pausing).finally(() => {
|
|
this.recordingPause = null;
|
|
});
|
|
await this.recordingPause;
|
|
}
|
|
|
|
private async changeRecordingPause(pausing: boolean): Promise<void> {
|
|
const recording = this.state.activeRecording;
|
|
|
|
if (
|
|
this.recordingStop ||
|
|
!recording ||
|
|
recording.status !== (pausing ? 'recording' : 'paused')
|
|
)
|
|
return;
|
|
recording.status = pausing ? 'pausing' : 'resuming';
|
|
this.events.emit();
|
|
try {
|
|
if (pausing)
|
|
await RecallAiSdk.pauseRecording({
|
|
windowId: recording.windowId,
|
|
});
|
|
else
|
|
await RecallAiSdk.resumeRecording({
|
|
windowId: recording.windowId,
|
|
});
|
|
if (
|
|
this.state.activeRecording !== recording ||
|
|
recording.status !== (pausing ? 'pausing' : 'resuming')
|
|
)
|
|
return;
|
|
if (pausing) recording.pausedAt = new Date().toISOString();
|
|
else {
|
|
recording.pausedMilliseconds =
|
|
(recording.pausedMilliseconds ?? 0) +
|
|
(recording.pausedAt
|
|
? Date.now() - Date.parse(recording.pausedAt)
|
|
: 0);
|
|
recording.pausedAt = undefined;
|
|
}
|
|
recording.status = pausing ? 'paused' : 'recording';
|
|
} catch (error) {
|
|
if (
|
|
this.state.activeRecording === recording &&
|
|
recording.status === (pausing ? 'pausing' : 'resuming')
|
|
)
|
|
recording.status = pausing ? 'recording' : 'paused';
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
stopRecording(): Promise<void> {
|
|
this.recordingStop ??= this.finishCapture().finally(() => {
|
|
this.recordingStop = null;
|
|
});
|
|
return this.recordingStop;
|
|
}
|
|
|
|
private async finishCapture(): Promise<void> {
|
|
await this.recordingStart?.catch(() => undefined);
|
|
await this.recordingPause?.catch(() => undefined);
|
|
const recording = this.state.activeRecording;
|
|
if (!recording) return;
|
|
const previousStatus = recording.status;
|
|
recording.status = 'stopping';
|
|
this.events.emit();
|
|
try {
|
|
await RecallAiSdk.stopRecording({ windowId: recording.windowId });
|
|
this.finishRecording(recording.windowId);
|
|
} catch (error) {
|
|
if (this.state.activeRecording === recording)
|
|
recording.status = previousStatus;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async initializeSdk(region?: string): Promise<void> {
|
|
if (this.sdkInitialization) await this.sdkInitialization;
|
|
if (this.events.isStopping()) return;
|
|
if (this.sdkRegion && (!region || region === this.sdkRegion)) return;
|
|
this.sdkInitialization = this.startSdk(region).finally(() => {
|
|
this.sdkInitialization = null;
|
|
});
|
|
await this.sdkInitialization;
|
|
}
|
|
|
|
private async startSdk(region?: string): Promise<void> {
|
|
if (process.platform !== 'darwin' || process.arch !== 'arm64')
|
|
throw new Error('Recording requires an Apple Silicon Mac.');
|
|
const [major, minor] = process.getSystemVersion().split('.').map(Number);
|
|
if (major < 14 || (major === 14 && minor < 2))
|
|
throw new Error('Audio recording requires macOS 14.2 or later.');
|
|
const apiUrl =
|
|
region ??
|
|
(
|
|
await this.client.companion<RecordingConfiguration>({
|
|
action: 'configuration',
|
|
})
|
|
).apiUrl;
|
|
if (!RECALL_REGIONS.has(apiUrl))
|
|
throw new Error('The workspace returned an unsupported Recall region.');
|
|
if (this.sdkRegion === apiUrl) return;
|
|
await this.stopSdk();
|
|
await RecallAiSdk.init({ apiUrl, acquirePermissionsOnStartup: [] });
|
|
this.sdkRegion = apiUrl;
|
|
this.refreshPermissions();
|
|
}
|
|
|
|
private async stopSdk(): Promise<void> {
|
|
if (this.sdkRegion) await RecallAiSdk.shutdown();
|
|
this.sdkRegion = null;
|
|
}
|
|
|
|
private async openPermissionSettings(
|
|
permission: 'microphone' | 'system-audio' | 'accessibility',
|
|
): Promise<void> {
|
|
const pane = {
|
|
microphone: 'Privacy_Microphone',
|
|
accessibility: 'Privacy_Accessibility',
|
|
'system-audio': 'Privacy_ScreenCapture',
|
|
}[permission];
|
|
await shell.openExternal(
|
|
`x-apple.systempreferences:com.apple.preference.security?${pane}`,
|
|
);
|
|
}
|
|
|
|
refreshPermissions(): void {
|
|
if (process.platform !== 'darwin') return;
|
|
const accessibility = systemPreferences.isTrustedAccessibilityClient(false);
|
|
const status = accessibility
|
|
? 'granted'
|
|
: this.state.permissions.accessibility === 'not_requested'
|
|
? 'not_requested'
|
|
: 'denied';
|
|
// Recall can retain a denial after approval in System Settings.
|
|
// Audio permissions belong to the SDK's capture process.
|
|
if (status !== this.state.permissions.accessibility) {
|
|
this.state.permissions.accessibility = status;
|
|
this.events.emit();
|
|
}
|
|
}
|
|
|
|
async requestPermission(
|
|
permission: 'microphone' | 'system-audio' | 'accessibility',
|
|
): Promise<void> {
|
|
this.refreshPermissions();
|
|
// A known denial must still open Settings when SDK initialization is unavailable.
|
|
if (this.state.permissions[permission] !== 'denied')
|
|
await this.initializeSdk();
|
|
this.refreshPermissions();
|
|
if (this.state.permissions[permission] === 'granted') return;
|
|
if (this.state.permissions[permission] === 'denied') {
|
|
await this.openPermissionSettings(permission);
|
|
return;
|
|
}
|
|
await RecallAiSdk.requestPermission(permission);
|
|
}
|
|
|
|
async record(windowId?: string): Promise<void> {
|
|
if (this.state.activeRecording || this.recordingStart) return;
|
|
if (!this.events.canRecord() || this.state.connection !== 'connected')
|
|
throw new Error('Connect your workspace before recording.');
|
|
if (!this.networkConnected)
|
|
throw new Error('Reconnect to the internet before recording.');
|
|
this.recordingStart = this.startRecording(windowId);
|
|
try {
|
|
await this.recordingStart;
|
|
} finally {
|
|
this.recordingStart = null;
|
|
}
|
|
}
|
|
|
|
private async startRecording(windowId?: string): Promise<void> {
|
|
let recordingId: string | undefined;
|
|
let selectedWindowId: string | undefined;
|
|
try {
|
|
await this.initializeSdk();
|
|
this.refreshPermissions();
|
|
const missingPermission = (
|
|
['microphone', 'system-audio', 'accessibility'] as const
|
|
).find((permission) => this.state.permissions[permission] !== 'granted');
|
|
if (missingPermission) {
|
|
this.state.permissionSetup = { windowId, intent: 'record' };
|
|
this.events.emit();
|
|
this.events.openApp();
|
|
return;
|
|
}
|
|
this.state.permissionSetup = null;
|
|
const detected = windowId
|
|
? this.state.detectedCalls.find((call) => call.id === windowId)
|
|
: undefined;
|
|
if (windowId && !detected)
|
|
throw new Error('This call has ended. Refresh and try again.');
|
|
if (
|
|
!this.state.updatedAt ||
|
|
Date.now() - Date.parse(this.state.updatedAt) > 90_000
|
|
)
|
|
throw new Error('Refresh your agenda before recording.');
|
|
const calendarEvent = matchMeeting({
|
|
meetings: this.state.meetings,
|
|
url: detected?.url,
|
|
now: Date.now(),
|
|
});
|
|
if (
|
|
calendarEvent &&
|
|
(calendarEvent.usesCalendarBot || !calendarEvent.recordingEnabled)
|
|
)
|
|
throw new Error(
|
|
'This meeting uses its calendar bot. Desktop recording is available for unscheduled calls.',
|
|
);
|
|
const title =
|
|
calendarEvent?.title ?? detected?.title ?? 'Unscheduled conversation';
|
|
recordingId = randomUUID();
|
|
const upload = await this.client.companion<RecordingUpload>({
|
|
action: 'create-upload',
|
|
sessionId: recordingId,
|
|
title,
|
|
calendarEventId: calendarEvent?.id,
|
|
meetingUrl: detected?.url,
|
|
platform: detected?.platform ?? 'desktop-audio',
|
|
});
|
|
await this.initializeSdk(upload.apiUrl);
|
|
selectedWindowId =
|
|
windowId ?? (await RecallAiSdk.prepareDesktopAudioRecording());
|
|
if (!this.events.canRecord() || !this.networkConnected)
|
|
throw new Error('Recording setup was interrupted. Try again.');
|
|
this.state.activeRecording = {
|
|
id: upload.callRecordingId,
|
|
windowId: selectedWindowId,
|
|
title,
|
|
startedAt: new Date().toISOString(),
|
|
status: 'starting',
|
|
};
|
|
this.events.emit();
|
|
await RecallAiSdk.startRecording({
|
|
windowId: selectedWindowId,
|
|
uploadToken: upload.uploadToken,
|
|
});
|
|
if (
|
|
this.state.activeRecording?.windowId === selectedWindowId &&
|
|
this.state.activeRecording.status === 'starting'
|
|
)
|
|
this.state.activeRecording.status = 'recording';
|
|
} catch (error) {
|
|
if (selectedWindowId && this.state.activeRecording) {
|
|
try {
|
|
await RecallAiSdk.stopRecording({ windowId: selectedWindowId });
|
|
} catch {
|
|
this.state.activeRecording.status = 'recording';
|
|
throw new Error(
|
|
'Capture may still be running. Use Stop recording or quit the app to stop it.',
|
|
);
|
|
}
|
|
}
|
|
this.state.activeRecording = null;
|
|
if (recordingId)
|
|
await this.client
|
|
.companion<RecordingResult>({
|
|
action: 'capture-failed',
|
|
sessionId: recordingId,
|
|
})
|
|
.catch(() => undefined);
|
|
throw error;
|
|
} finally {
|
|
this.events.emit();
|
|
}
|
|
}
|
|
|
|
listenToRecording(): void {
|
|
const listen = <TEvent extends keyof EventTypeToPayloadMap>(
|
|
event: TEvent,
|
|
listener: (payload: EventTypeToPayloadMap[TEvent]) => void,
|
|
) => {
|
|
RecallAiSdk.addEventListener(event, listener);
|
|
this.unsubscribeRecording.push(() =>
|
|
RecallAiSdk.removeEventListener(event, listener),
|
|
);
|
|
};
|
|
const detected = (window: RecallAiSdkWindow) => {
|
|
const call = {
|
|
id: window.id,
|
|
title: window.title ?? 'Detected call',
|
|
platform: window.platform ?? 'Meeting',
|
|
url: window.url,
|
|
};
|
|
this.state.detectedCalls = [
|
|
...this.state.detectedCalls.filter((item) => item.id !== call.id),
|
|
call,
|
|
];
|
|
this.events.emit();
|
|
};
|
|
const handleDetection = (
|
|
window: RecallAiSdkWindow,
|
|
shouldNotify: boolean,
|
|
) => {
|
|
if (this.events.isStopping() || this.state.connection !== 'connected')
|
|
return;
|
|
detected(window);
|
|
// Calendar meetings keep their scheduled bot; unsupported platforms can use desktop capture.
|
|
const meeting = matchMeeting({
|
|
meetings: this.state.meetings,
|
|
url: window.url,
|
|
now: Date.now(),
|
|
});
|
|
if (
|
|
this.state.settings.autoRecord &&
|
|
this.state.settings.setupCompleted &&
|
|
this.networkConnected &&
|
|
(!meeting || (meeting.recordingEnabled && !meeting.usesCalendarBot)) &&
|
|
window.url &&
|
|
this.state.updatedAt &&
|
|
Date.now() - Date.parse(this.state.updatedAt) < 90_000 &&
|
|
!this.autoHandledWindows.has(window.id)
|
|
) {
|
|
this.autoHandledWindows.add(window.id);
|
|
void this.record(window.id)
|
|
.catch((error) => this.events.fail(error))
|
|
.finally(() => {
|
|
if (this.state.activeRecording?.windowId !== window.id)
|
|
this.autoHandledWindows.delete(window.id);
|
|
});
|
|
} else if (
|
|
shouldNotify &&
|
|
this.state.settings.notifyOnDetectedCall &&
|
|
!meeting?.usesCalendarBot
|
|
)
|
|
this.events.notify(
|
|
'Call detected',
|
|
'Open Twenty to record this conversation.',
|
|
);
|
|
};
|
|
listen('meeting-detected', ({ window }) => handleDetection(window, true));
|
|
listen('meeting-updated', ({ window }) => handleDetection(window, false));
|
|
listen('meeting-closed', ({ window }) => {
|
|
this.autoHandledWindows.delete(window.id);
|
|
this.state.detectedCalls = this.state.detectedCalls.filter(
|
|
(call) => call.id !== window.id,
|
|
);
|
|
this.events.emit();
|
|
});
|
|
listen('recording-started', ({ window }) => {
|
|
if (
|
|
this.state.activeRecording?.windowId === window.id &&
|
|
this.state.activeRecording.status === 'starting'
|
|
)
|
|
this.state.activeRecording.status = 'recording';
|
|
this.events.emit();
|
|
});
|
|
listen('recording-ended', ({ window }) => this.finishRecording(window.id));
|
|
listen('permission-status', ({ permission, status }) => {
|
|
if (
|
|
permission === 'microphone' ||
|
|
permission === 'accessibility' ||
|
|
permission === 'system-audio'
|
|
)
|
|
this.state.permissions[permission] = status;
|
|
this.refreshPermissions();
|
|
this.events.emit();
|
|
});
|
|
listen('network-status', ({ status }) => {
|
|
this.networkConnected = status !== 'disconnected';
|
|
if (!this.networkConnected && this.state.activeRecording) {
|
|
this.interruptedByNetwork = true;
|
|
this.state.notice = { type: 'network-lost' };
|
|
this.events.notify(
|
|
'Recording interrupted',
|
|
'Network connection lost. Stopping this recording. Reconnect before starting a new recording.',
|
|
);
|
|
void this.stopRecording().catch((error) => this.events.fail(error));
|
|
} else if (this.networkConnected && this.interruptedByNetwork) {
|
|
this.interruptedByNetwork = false;
|
|
this.state.notice = this.state.activeRecording
|
|
? { type: 'network-restored-active' }
|
|
: { type: 'network-restored' };
|
|
}
|
|
this.events.emit();
|
|
});
|
|
listen('error', () => {
|
|
this.events.fail(
|
|
new Error(
|
|
this.state.activeRecording
|
|
? 'Recall reported a capture error. Stop this recording before starting another.'
|
|
: 'Recording is unavailable. Check permissions and try again.',
|
|
),
|
|
);
|
|
});
|
|
listen('media-capture-status', ({ type, capturing, window }) => {
|
|
if (
|
|
type === 'audio' &&
|
|
!capturing &&
|
|
this.state.activeRecording?.windowId === window.id &&
|
|
this.state.activeRecording.status === 'recording'
|
|
) {
|
|
this.state.notice = { type: 'audio-interrupted' };
|
|
this.events.emit();
|
|
}
|
|
});
|
|
}
|
|
|
|
private finishRecording(windowId: string): void {
|
|
if (this.state.activeRecording?.windowId !== windowId) return;
|
|
const recording = this.state.activeRecording;
|
|
this.state.recordings = [
|
|
{
|
|
id: recording.id,
|
|
title: recording.title,
|
|
status: 'PROCESSING',
|
|
startedAt: recording.startedAt,
|
|
endedAt: new Date().toISOString(),
|
|
},
|
|
...this.state.recordings.filter((item) => item.id !== recording.id),
|
|
];
|
|
this.state.activeRecording = null;
|
|
this.state.notice = this.networkConnected
|
|
? { type: 'recording-finished' }
|
|
: { type: 'recording-stopped-offline' };
|
|
this.events.emit();
|
|
void this.events.refresh().catch((error) => this.events.fail(error));
|
|
}
|
|
}
|