From 8a8ddd2ddc2a801b0d107eb3ac524d3ec955321b Mon Sep 17 00:00:00 2001 From: Lazare Rossillon Date: Thu, 25 Jun 2026 10:40:01 +0200 Subject: [PATCH] feat(call-recorder): support Meeting BaaS provider --- .../public/call-recorder/README.md | 34 ++- .../call-recorder/src/application-config.ts | 36 ++- ...ook-logic-function-universal-identifier.ts | 2 + .../call-recorder-provider-env-var-name.ts | 1 + .../meeting-baas-api-base-url-env-var-name.ts | 2 + .../meeting-baas-api-key-env-var-name.ts | 1 + ...eting-baas-callback-secret-env-var-name.ts | 2 + .../meeting-baas-callback-url-env-var-name.ts | 2 + ...as-status-to-call-recording-status.util.ts | 22 ++ .../heal-call-recordings-missing-bot.test.ts | 4 +- .../__tests__/reconcile-call-recorder.test.ts | 12 +- .../cancel-call-recording-request.util.ts | 8 +- .../converge-diverged-call-recordings.util.ts | 269 +++++++++++++++++- .../flows/ensure-call-recorder.util.ts | 10 +- .../flows/handle-meeting-baas-webhook.util.ts | 194 +++++++++++++ .../flows/ingest-call-recording-media.util.ts | 53 ++++ .../reap-orphaned-call-recorders.util.ts | 5 + .../reschedule-call-recording-bot.util.ts | 15 +- .../cancel-meeting-baas-bot.util.ts | 24 ++ .../get-meeting-baas-api-config.util.ts | 80 ++++++ .../get-meeting-baas-bot.util.ts | 33 +++ .../meeting-baas-api-request.util.ts | 99 +++++++ .../normalize-meeting-baas-transcript.util.ts | 109 +++++++ .../parse-meeting-baas-webhook-event.util.ts | 67 +++++ .../reschedule-meeting-baas-bot.util.ts | 52 ++++ .../schedule-meeting-baas-bot.util.ts | 104 +++++++ .../logic-functions/meeting-baas-webhook.ts | 61 ++++ .../providers/call-recorder-provider.type.ts | 1 + .../cancel-call-recorder-bot.util.ts | 16 ++ .../get-call-recorder-provider.util.ts | 11 + .../reschedule-call-recorder-bot.util.ts | 33 +++ .../schedule-call-recorder-bot.util.ts | 21 ++ 32 files changed, 1350 insertions(+), 33 deletions(-) create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/meeting-baas-webhook-logic-function-universal-identifier.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-provider-env-var-name.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/meeting-baas-api-base-url-env-var-name.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/meeting-baas-api-key-env-var-name.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/meeting-baas-callback-secret-env-var-name.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/meeting-baas-callback-url-env-var-name.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/domain/map-meeting-baas-status-to-call-recording-status.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/flows/handle-meeting-baas-webhook.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/cancel-meeting-baas-bot.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/get-meeting-baas-api-config.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/get-meeting-baas-bot.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/meeting-baas-api-request.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/normalize-meeting-baas-transcript.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/parse-meeting-baas-webhook-event.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/reschedule-meeting-baas-bot.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/schedule-meeting-baas-bot.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-webhook.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/providers/call-recorder-provider.type.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/providers/cancel-call-recorder-bot.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/providers/get-call-recorder-provider.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/providers/reschedule-call-recorder-bot.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/providers/schedule-call-recorder-bot.util.ts diff --git a/packages/twenty-apps/public/call-recorder/README.md b/packages/twenty-apps/public/call-recorder/README.md index e75729bcb7..9838058152 100644 --- a/packages/twenty-apps/public/call-recorder/README.md +++ b/packages/twenty-apps/public/call-recorder/README.md @@ -125,10 +125,15 @@ Applications → Call Recorder): | Server variable | Required | Purpose | |---|---|---| -| `RECALL_API_KEY` | Yes | Recall.ai API key for the configured region; used to schedule, update, and cancel bots. | +| `CALL_RECORDER_PROVIDER` | No | Recording provider. Use `recall` (default) or `meeting-baas`. | +| `RECALL_API_KEY` | Provider | Recall.ai API key for the configured region; required when `CALL_RECORDER_PROVIDER` is `recall`. | | `RECALL_REGION` | No | Recall.ai region for API requests. Defaults to `eu-central-1` (Europe / Frankfurt). | | `CALL_RECORDER_RECORDING_RETENTION_HOURS` | No | How long Recall.ai retains the source media after processing. Defaults to `166` hours (6 days 22 hours), just under Recall's 168-hour free-storage window. Values above `168` may incur Recall storage charges. Twenty's ingested copy is unaffected. | -| `RECALL_WEBHOOK_SECRET` | Yes | Svix signing secret (`whsec_…`) used to verify incoming Recall webhooks. | +| `RECALL_WEBHOOK_SECRET` | Provider | Svix signing secret (`whsec_…`) used to verify incoming Recall webhooks; required when `CALL_RECORDER_PROVIDER` is `recall`. | +| `MEETING_BAAS_API_KEY` | Provider | Meeting BaaS API key; required when `CALL_RECORDER_PROVIDER` is `meeting-baas`. A Sending Access key can schedule bots; Full Access is needed for cancellation and reconciliation. | +| `MEETING_BAAS_API_BASE_URL` | No | Meeting BaaS API base URL. Defaults to `https://api.meetingbaas.com`. | +| `MEETING_BAAS_CALLBACK_URL` | Provider | Absolute Twenty server webhook URL for `meeting-baas-webhook`; required when `CALL_RECORDER_PROVIDER` is `meeting-baas`. | +| `MEETING_BAAS_CALLBACK_SECRET` | Provider | Shared secret sent by Meeting BaaS in the `x-mb-secret` callback header; required when `CALL_RECORDER_PROVIDER` is `meeting-baas`. | ### Application variables @@ -171,3 +176,28 @@ ID is the **Recall webhook logic function**. 3. Set it as the `RECALL_WEBHOOK_SECRET` server variable on the **Call Recorder** application registration. 4. Set `RECALL_API_KEY` (and optionally `RECALL_REGION`) the same way. + +### Configuring Meeting BaaS + +Set `CALL_RECORDER_PROVIDER=meeting-baas`, then set `MEETING_BAAS_API_KEY`, +`MEETING_BAAS_CALLBACK_URL`, and `MEETING_BAAS_CALLBACK_SECRET`. + +Use this URL on your deployment for `MEETING_BAAS_CALLBACK_URL`, replacing only +the host: + +```text +https:///webhooks/server/8da4b8b5-5edf-4880-b51f-ab6e679ec617/7a30ca04-7e6e-4a85-96d0-7e16adc3e2f1 +``` + +The app passes this URL to Meeting BaaS in each scheduled bot's +`callback_config`. Meeting BaaS should send completed and failed bot callbacks +with the app metadata in `data.extra`; the Twenty webhook resolver reads +`data.extra.twentyWorkspaceId`. + +Meeting BaaS docs: + +- API v2: https://docs.meetingbaas.com/api-v2 +- Scheduled bots: https://docs.meetingbaas.com/api-v2/reference/bots/createScheduledBot +- Artifacts and callbacks: https://docs.meetingbaas.com/api-v2/getting-started/getting-the-data +- Bot status: https://docs.meetingbaas.com/api-v2/reference/bots/getBotStatus +- API keys: https://docs.meetingbaas.com/api-v2/api-keys 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 000771fa47..81277de171 100644 --- a/packages/twenty-apps/public/call-recorder/src/application-config.ts +++ b/packages/twenty-apps/public/call-recorder/src/application-config.ts @@ -14,6 +14,7 @@ import { CALL_RECORDER_JOIN_EARLY_MINUTES_ENV_VAR_NAME } from 'src/logic-functio import { CALL_RECORDER_NAME_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-name-env-var-name'; 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_PROVIDER_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-provider-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_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'; @@ -24,6 +25,10 @@ import { DEFAULT_RECALL_REGION } from 'src/logic-functions/constants/default-rec 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'; import { RECALL_WEBHOOK_SECRET_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-webhook-secret-env-var-name'; +import { MEETING_BAAS_API_BASE_URL_ENV_VAR_NAME } from 'src/logic-functions/constants/meeting-baas-api-base-url-env-var-name'; +import { MEETING_BAAS_API_KEY_ENV_VAR_NAME } from 'src/logic-functions/constants/meeting-baas-api-key-env-var-name'; +import { MEETING_BAAS_CALLBACK_SECRET_ENV_VAR_NAME } from 'src/logic-functions/constants/meeting-baas-callback-secret-env-var-name'; +import { MEETING_BAAS_CALLBACK_URL_ENV_VAR_NAME } from 'src/logic-functions/constants/meeting-baas-callback-url-env-var-name'; export default defineApplication({ universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, @@ -72,11 +77,15 @@ export default defineApplication({ }, }, serverVariables: { + [CALL_RECORDER_PROVIDER_ENV_VAR_NAME]: { + description: + 'Recording provider to use. Supported values: recall, meeting-baas. Defaults to recall when unset.', + isSecret: false, + }, [RECALL_API_KEY_ENV_VAR_NAME]: { description: - 'Recall.ai API key for the configured region. Set by the server admin on this registration after installation; used to create, update, and cancel scheduled recording bots.', + 'Recall.ai API key for the configured region. Required when CALL_RECORDER_PROVIDER is recall.', isSecret: true, - isRequired: true, }, [RECALL_REGION_ENV_VAR_NAME]: { description: `Recall.ai region used for API requests. Defaults to ${DEFAULT_RECALL_REGION} when unset. Europe Frankfurt is eu-central-1.`, @@ -88,9 +97,28 @@ export default defineApplication({ }, [RECALL_WEBHOOK_SECRET_ENV_VAR_NAME]: { description: - 'Recall.ai webhook signing secret (whsec_...). Set by the server admin from the Recall webhook endpoint settings; used to verify the Svix signature of incoming Recall webhook deliveries.', + 'Recall.ai webhook signing secret (whsec_...). Required when CALL_RECORDER_PROVIDER is recall.', + isSecret: true, + }, + [MEETING_BAAS_API_KEY_ENV_VAR_NAME]: { + description: + 'Meeting BaaS API key. Required when CALL_RECORDER_PROVIDER is meeting-baas.', + isSecret: true, + }, + [MEETING_BAAS_API_BASE_URL_ENV_VAR_NAME]: { + description: + 'Meeting BaaS API base URL. Defaults to https://api.meetingbaas.com when unset.', + isSecret: false, + }, + [MEETING_BAAS_CALLBACK_URL_ENV_VAR_NAME]: { + description: + 'Absolute Twenty server webhook URL for the meeting-baas-webhook logic function. Required when CALL_RECORDER_PROVIDER is meeting-baas.', + isSecret: false, + }, + [MEETING_BAAS_CALLBACK_SECRET_ENV_VAR_NAME]: { + description: + 'Shared secret sent by Meeting BaaS in the x-mb-secret header. Required when CALL_RECORDER_PROVIDER is meeting-baas.', isSecret: true, - isRequired: true, }, }, }); diff --git a/packages/twenty-apps/public/call-recorder/src/constants/meeting-baas-webhook-logic-function-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/meeting-baas-webhook-logic-function-universal-identifier.ts new file mode 100644 index 0000000000..cbfaf079c4 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/constants/meeting-baas-webhook-logic-function-universal-identifier.ts @@ -0,0 +1,2 @@ +export const MEETING_BAAS_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = + '7a30ca04-7e6e-4a85-96d0-7e16adc3e2f1'; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-provider-env-var-name.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-provider-env-var-name.ts new file mode 100644 index 0000000000..3577cd7d18 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-provider-env-var-name.ts @@ -0,0 +1 @@ +export const CALL_RECORDER_PROVIDER_ENV_VAR_NAME = 'CALL_RECORDER_PROVIDER'; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/meeting-baas-api-base-url-env-var-name.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/meeting-baas-api-base-url-env-var-name.ts new file mode 100644 index 0000000000..a11fcd26f9 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/meeting-baas-api-base-url-env-var-name.ts @@ -0,0 +1,2 @@ +export const MEETING_BAAS_API_BASE_URL_ENV_VAR_NAME = + 'MEETING_BAAS_API_BASE_URL'; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/meeting-baas-api-key-env-var-name.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/meeting-baas-api-key-env-var-name.ts new file mode 100644 index 0000000000..bad2289d08 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/meeting-baas-api-key-env-var-name.ts @@ -0,0 +1 @@ +export const MEETING_BAAS_API_KEY_ENV_VAR_NAME = 'MEETING_BAAS_API_KEY'; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/meeting-baas-callback-secret-env-var-name.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/meeting-baas-callback-secret-env-var-name.ts new file mode 100644 index 0000000000..6b777d4f49 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/meeting-baas-callback-secret-env-var-name.ts @@ -0,0 +1,2 @@ +export const MEETING_BAAS_CALLBACK_SECRET_ENV_VAR_NAME = + 'MEETING_BAAS_CALLBACK_SECRET'; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/meeting-baas-callback-url-env-var-name.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/meeting-baas-callback-url-env-var-name.ts new file mode 100644 index 0000000000..98816ea095 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/meeting-baas-callback-url-env-var-name.ts @@ -0,0 +1,2 @@ +export const MEETING_BAAS_CALLBACK_URL_ENV_VAR_NAME = + 'MEETING_BAAS_CALLBACK_URL'; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/map-meeting-baas-status-to-call-recording-status.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/map-meeting-baas-status-to-call-recording-status.util.ts new file mode 100644 index 0000000000..7cf2392d4f --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/map-meeting-baas-status-to-call-recording-status.util.ts @@ -0,0 +1,22 @@ +import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status'; + +export const mapMeetingBaasStatusToCallRecordingStatus = ( + status: string | undefined, +): CallRecordingStatus | undefined => { + switch (status) { + case 'queued': + return CallRecordingStatus.SCHEDULED; + case 'joining': + return CallRecordingStatus.JOINING; + case 'in_call_recording': + return CallRecordingStatus.RECORDING; + case 'transcribing': + return CallRecordingStatus.PROCESSING; + case 'completed': + return CallRecordingStatus.COMPLETED; + case 'failed': + return CallRecordingStatus.FAILED; + default: + return undefined; + } +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/heal-call-recordings-missing-bot.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/heal-call-recordings-missing-bot.test.ts index 4d732bd9c1..3bb493d00f 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/heal-call-recordings-missing-bot.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/heal-call-recordings-missing-bot.test.ts @@ -10,8 +10,8 @@ vi.mock('src/logic-functions/data/get-current-workspace-id.util', () => ({ getCurrentWorkspaceId: getCurrentWorkspaceIdMock, })); -vi.mock('src/logic-functions/recall-api/schedule-recall-bot.util', () => ({ - scheduleRecallBot: scheduleRecallBotMock, +vi.mock('src/logic-functions/providers/schedule-call-recorder-bot.util', () => ({ + scheduleCallRecorderBot: scheduleRecallBotMock, })); const NOW = new Date('2026-01-01T12:00:00.000Z'); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/reconcile-call-recorder.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/reconcile-call-recorder.test.ts index bb8ba52c7b..f3893a16f5 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/reconcile-call-recorder.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/reconcile-call-recorder.test.ts @@ -13,16 +13,16 @@ vi.mock('src/logic-functions/data/get-current-workspace-id.util', () => ({ getCurrentWorkspaceId: getCurrentWorkspaceIdMock, })); -vi.mock('src/logic-functions/recall-api/schedule-recall-bot.util', () => ({ - scheduleRecallBot: scheduleRecallBotMock, +vi.mock('src/logic-functions/providers/schedule-call-recorder-bot.util', () => ({ + scheduleCallRecorderBot: scheduleRecallBotMock, })); -vi.mock('src/logic-functions/recall-api/reschedule-recall-bot.util', () => ({ - rescheduleRecallBot: rescheduleRecallBotMock, +vi.mock('src/logic-functions/providers/reschedule-call-recorder-bot.util', () => ({ + rescheduleCallRecorderBot: rescheduleRecallBotMock, })); -vi.mock('src/logic-functions/recall-api/cancel-recall-bot.util', () => ({ - cancelRecallBot: cancelRecallBotMock, +vi.mock('src/logic-functions/providers/cancel-call-recorder-bot.util', () => ({ + cancelCallRecorderBot: cancelRecallBotMock, })); const NOW = new Date('2026-01-01T12:00:00.000Z'); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/cancel-call-recording-request.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/cancel-call-recording-request.util.ts index a3f9e1676e..2602f91b0a 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/cancel-call-recording-request.util.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/cancel-call-recording-request.util.ts @@ -3,10 +3,10 @@ import { type CoreApiClient } from 'twenty-client-sdk/core'; import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status'; import { type CallRecordingRecord } from 'src/logic-functions/types/call-recording-record.type'; -import { cancelRecallBot } from 'src/logic-functions/recall-api/cancel-recall-bot.util'; +import { cancelCallRecorderBot } from 'src/logic-functions/providers/cancel-call-recorder-bot.util'; import { updateCallRecording } from 'src/logic-functions/data/update-call-recording.util'; -// Intent-first: the stale-state cron finishes the Recall half when this call fails. +// Intent-first: the stale-state cron finishes the provider half when this call fails. export const cancelCallRecordingRequest = async ({ client, callRecording, @@ -25,13 +25,13 @@ export const cancelCallRecordingRequest = async ({ return; } - const cancelResult = await cancelRecallBot({ + const cancelResult = await cancelCallRecorderBot({ externalBotId: callRecording.externalBotId, }); if (!cancelResult.ok) { console.warn( - `[call-recorder] failed to cancel Recall bot for callRecording ${callRecording.id}, leaving it for the stale-state cron: ${cancelResult.errorMessage}`, + `[call-recorder] failed to cancel provider bot for callRecording ${callRecording.id}, leaving it for the stale-state cron: ${cancelResult.errorMessage}`, ); return; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/converge-diverged-call-recordings.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/converge-diverged-call-recordings.util.ts index ed712296b3..cc61273915 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/converge-diverged-call-recordings.util.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/converge-diverged-call-recordings.util.ts @@ -15,7 +15,10 @@ import { type ConnectionPage, } from 'src/logic-functions/data/fetch-all-nodes.util'; import { getRecallBot } from 'src/logic-functions/recall-api/get-recall-bot.util'; -import { ingestCallRecordingMedia } from 'src/logic-functions/flows/ingest-call-recording-media.util'; +import { + ingestCallRecordingMedia, + ingestCallRecordingMediaFromUrls, +} from 'src/logic-functions/flows/ingest-call-recording-media.util'; import { isCallRecordingStatusDowngrade } from 'src/logic-functions/domain/is-call-recording-status-downgrade.util'; import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; import { parseTranscriptMarker } from 'src/logic-functions/domain/parse-transcript-marker.util'; @@ -27,6 +30,12 @@ import { updateCallRecording, type CallRecordingUpdateFields, } from 'src/logic-functions/data/update-call-recording.util'; +import { mapMeetingBaasStatusToCallRecordingStatus } from 'src/logic-functions/domain/map-meeting-baas-status-to-call-recording-status.util'; +import { getMeetingBaasBot } from 'src/logic-functions/meeting-baas-api/get-meeting-baas-bot.util'; +import { normalizeMeetingBaasTranscript } from 'src/logic-functions/meeting-baas-api/normalize-meeting-baas-transcript.util'; +import { getCallRecorderProvider } from 'src/logic-functions/providers/get-call-recorder-provider.util'; +import { asRecord } from 'src/logic-functions/utils/as-record.util'; +import { getString } from 'src/logic-functions/utils/get-string.util'; const CONVERGENCE_LOOKBACK_DAYS = 7; @@ -59,7 +68,20 @@ type DivergedCallRecordingNode = { calendarEvent?: { startsAt?: string | null; endsAt?: string | null } | null; }; -// Webhook deliveries get lost; this pull pass re-derives state from Recall. +type ProviderBotConvergence = { + status: CallRecordingStatus | undefined; + startedAt: string | undefined; + endedAt: string | undefined; + externalRecordingId: string | undefined; + isRecordingDone: boolean; + failureReason: string | undefined; + audioUrl?: string; + videoUrl?: string; + transcriptionUrl?: string; + rawTranscriptionUrl?: string; +}; + +// Webhook deliveries get lost; this pull pass re-derives state from the configured provider. export const convergeDivergedCallRecordings = async ({ client, now, @@ -91,7 +113,7 @@ export const convergeDivergedCallRecordings = async ({ if (isUndefined(candidate.externalBotId)) { console.warn( - `[call-recorder] call recording ${candidate.id} diverged but has no Recall bot id; it will not converge automatically`, + `[call-recorder] call recording ${candidate.id} diverged but has no provider bot id; it will not converge automatically`, ); result.unconvergeableCallRecordingIds.push(candidate.id); continue; @@ -226,6 +248,17 @@ const convergeCallRecording = async ({ now: Date; result: ConvergeDivergedCallRecordingsResult; }): Promise => { + if (getCallRecorderProvider() === 'meeting-baas') { + await convergeMeetingBaasCallRecording({ + client, + candidate, + externalBotId, + result, + }); + + return; + } + const botResult = await getRecallBot({ externalBotId }); if (!botResult.ok) { @@ -234,6 +267,8 @@ const convergeCallRecording = async ({ client, candidate, externalBotId, + providerLabel: 'Recall', + failureReason: 'recall_bot_not_found', result, }); @@ -310,6 +345,98 @@ const convergeCallRecording = async ({ result.updatedCallRecordingIds.push(candidate.id); }; +const convergeMeetingBaasCallRecording = async ({ + client, + candidate, + externalBotId, + result, +}: { + client: CoreApiClient; + candidate: DivergedCallRecordingCandidate; + externalBotId: string; + result: ConvergeDivergedCallRecordingsResult; +}): Promise => { + const botResult = await getMeetingBaasBot({ externalBotId }); + + if (!botResult.ok) { + if (botResult.status === 404) { + await markCallRecordingFailedAfterBotLoss({ + client, + candidate, + externalBotId, + providerLabel: 'Meeting BaaS', + failureReason: 'meeting_baas_bot_not_found', + result, + }); + + return; + } + + console.warn( + `[call-recorder] failed to fetch Meeting BaaS bot ${externalBotId} for call recording ${candidate.id}: ${botResult.errorMessage}`, + ); + + return; + } + + const convergence = extractMeetingBaasConvergence(botResult.bot); + const updateData = buildProviderConvergenceFieldUpdates({ + candidate, + convergence, + providerFailureFallback: 'meeting_baas_bot_failed', + }); + const externalRecordingId = + candidate.externalRecordingId ?? convergence.externalRecordingId; + + if (convergence.isRecordingDone && !isUndefined(externalRecordingId)) { + Object.assign( + updateData, + await ingestCallRecordingMediaFromUrls({ + callRecordingId: candidate.id, + hasAudio: isNonEmptyArray(candidate.audio), + hasVideo: isNonEmptyArray(candidate.video), + audioUrl: convergence.audioUrl, + videoUrl: convergence.videoUrl, + }), + ); + + const transcript = await resolveMeetingBaasTranscript(convergence); + + if (!isUndefined(transcript) && isUndefined(candidate.transcript)) { + updateData.transcript = normalizeTranscriptUpdate(transcript); + } + } + + const terminalArtifactGateFailureUpdate = + buildMeetingBaasTerminalArtifactGateFailureUpdate({ + candidate, + convergence, + externalRecordingId, + updateData, + }); + + if (!isUndefined(terminalArtifactGateFailureUpdate)) { + Object.assign(updateData, terminalArtifactGateFailureUpdate); + } + + const completesIngestion = shouldCompleteCallRecordingIngestion({ + current: candidate, + updateData, + }); + + if (Object.keys(updateData).length === 0 && !completesIngestion) { + return; + } + + await persistCallRecordingProgress(client, { + id: candidate.id, + current: candidate, + updateData, + }); + + result.updatedCallRecordingIds.push(candidate.id); +}; + // Pure merge: fill only unset candidate fields and never downgrade status. const buildConvergenceFieldUpdates = ({ candidate, @@ -354,6 +481,51 @@ const buildConvergenceFieldUpdates = ({ return updateData; }; +const buildProviderConvergenceFieldUpdates = ({ + candidate, + convergence, + providerFailureFallback, +}: { + candidate: DivergedCallRecordingCandidate; + convergence: ProviderBotConvergence; + providerFailureFallback: string; +}): CallRecordingUpdateFields => { + const updateData: CallRecordingUpdateFields = {}; + + if ( + !isUndefined(convergence.status) && + convergence.status !== candidate.status && + !isCallRecordingStatusDowngrade({ + fromStatus: candidate.status, + toStatus: convergence.status, + }) + ) { + updateData.status = convergence.status; + + if (convergence.status === CallRecordingStatus.FAILED) { + updateData.callRecorderFailureReason = + convergence.failureReason ?? providerFailureFallback; + } + } + + if (isUndefined(candidate.startedAt) && !isUndefined(convergence.startedAt)) { + updateData.startedAt = convergence.startedAt; + } + + if (isUndefined(candidate.endedAt) && !isUndefined(convergence.endedAt)) { + updateData.endedAt = convergence.endedAt; + } + + if ( + isUndefined(candidate.externalRecordingId) && + !isUndefined(convergence.externalRecordingId) + ) { + updateData.externalRecordingId = convergence.externalRecordingId; + } + + return updateData; +}; + type TerminalArtifactGateFailureUpdate = { status: CallRecordingStatus.FAILED; callRecorderFailureReason: string; @@ -387,6 +559,34 @@ const buildTerminalArtifactGateFailureUpdate = ({ }; }; +const buildMeetingBaasTerminalArtifactGateFailureUpdate = ({ + candidate, + convergence, + externalRecordingId, + updateData, +}: { + candidate: DivergedCallRecordingCandidate; + convergence: ProviderBotConvergence; + externalRecordingId: string | undefined; + updateData: CallRecordingUpdateFields; +}): TerminalArtifactGateFailureUpdate | undefined => { + if ( + candidate.status === CallRecordingStatus.COMPLETED || + updateData.status === CallRecordingStatus.FAILED || + !convergence.isRecordingDone || + !isUndefined(externalRecordingId) || + hasRecordingArtifactPath({ candidate, updateData }) + ) { + return undefined; + } + + return { + status: CallRecordingStatus.FAILED, + callRecorderFailureReason: + convergence.failureReason ?? 'recording_artifacts_unavailable', + }; +}; + const hasRecordingArtifactPath = ({ candidate, updateData, @@ -415,16 +615,20 @@ const markCallRecordingFailedAfterBotLoss = async ({ client, candidate, externalBotId, + providerLabel, + failureReason, result, }: { client: CoreApiClient; candidate: DivergedCallRecordingCandidate; externalBotId: string; + providerLabel: string; + failureReason: string; result: ConvergeDivergedCallRecordingsResult; }): Promise => { - // externalBotId is kept for audit even though the bot is gone at Recall. + // externalBotId is kept for audit even though the provider bot is gone. console.warn( - `[call-recorder] Recall bot ${externalBotId} for call recording ${candidate.id} no longer exists; it will not converge automatically`, + `[call-recorder] ${providerLabel} bot ${externalBotId} for call recording ${candidate.id} no longer exists; it will not converge automatically`, ); if ( @@ -442,8 +646,61 @@ const markCallRecordingFailedAfterBotLoss = async ({ id: candidate.id, data: { status: CallRecordingStatus.FAILED, - callRecorderFailureReason: 'recall_bot_not_found', + callRecorderFailureReason: failureReason, }, }); result.markedFailedCallRecordingIds.push(candidate.id); }; + +const extractMeetingBaasConvergence = ( + bot: Record, +): ProviderBotConvergence => { + const status = getString(bot.status); + + return { + status: mapMeetingBaasStatusToCallRecordingStatus(status), + startedAt: getString(bot.joined_at), + endedAt: getString(bot.exited_at), + externalRecordingId: getString(bot.bot_id), + isRecordingDone: status === 'completed', + failureReason: + getString(bot.error_message) ?? getString(bot.error_code) ?? status, + audioUrl: getString(bot.audio), + videoUrl: getString(bot.video), + transcriptionUrl: getString(bot.transcription), + rawTranscriptionUrl: getString(bot.raw_transcription), + }; +}; + +const resolveMeetingBaasTranscript = async ( + convergence: ProviderBotConvergence, +): Promise => { + const transcriptUrl = + convergence.transcriptionUrl ?? convergence.rawTranscriptionUrl; + + if (isUndefined(transcriptUrl)) { + return undefined; + } + + try { + const response = await fetch(transcriptUrl, { + signal: AbortSignal.timeout(120_000), + }); + + if (!response.ok) { + return undefined; + } + + const transcript = await response.json(); + const transcriptRecord = asRecord(transcript); + + return transcriptRecord?.transcript ?? transcript; + } catch { + return undefined; + } +}; + +const normalizeTranscriptUpdate = ( + transcript: unknown, +): Record => + normalizeMeetingBaasTranscript(transcript) as Record; 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 2fe38ab658..79d30ca20a 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 @@ -7,10 +7,10 @@ import { buildRecallBotMetadata } from 'src/logic-functions/domain/build-recall- 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'; import { getCurrentWorkspaceId } from 'src/logic-functions/data/get-current-workspace-id.util'; -import { scheduleRecallBot } from 'src/logic-functions/recall-api/schedule-recall-bot.util'; +import { scheduleCallRecorderBot } from 'src/logic-functions/providers/schedule-call-recorder-bot.util'; import { updateCallRecording } from 'src/logic-functions/data/update-call-recording.util'; -// The sole place a Recall bot is created. Only the deterministic-create winner and the stale-state cron call it, so one writer per meeting POSTs exactly one bot. +// The sole place a provider bot is created. Only the deterministic-create winner and the stale-state cron call it, so one writer per meeting POSTs exactly one bot. export const ensureCallRecorder = async ( client: CoreApiClient, { callRecording, calendarEvent }: MeetingRecording, @@ -41,13 +41,13 @@ export const ensureCallRecorder = async ( if (isUndefined(workspaceId)) { console.error( - `[call-recorder] cannot schedule Recall bot for callRecording ${callRecording.id}: workspace id unavailable, the shared webhook could not be routed back`, + `[call-recorder] cannot schedule provider bot for callRecording ${callRecording.id}: workspace id unavailable, the shared webhook could not be routed back`, ); return false; } - const scheduleResult = await scheduleRecallBot({ + const scheduleResult = await scheduleCallRecorderBot({ meetingUrl, joinAt, metadata: buildRecallBotMetadata({ @@ -59,7 +59,7 @@ export const ensureCallRecorder = async ( if (!scheduleResult.ok) { console.warn( - `[call-recorder] failed to schedule Recall bot for callRecording ${callRecording.id}: ${scheduleResult.errorMessage}`, + `[call-recorder] failed to schedule provider bot for callRecording ${callRecording.id}: ${scheduleResult.errorMessage}`, ); return false; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/handle-meeting-baas-webhook.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/handle-meeting-baas-webhook.util.ts new file mode 100644 index 0000000000..74fde047c6 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/handle-meeting-baas-webhook.util.ts @@ -0,0 +1,194 @@ +import { isUndefined } from '@sniptt/guards'; +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status'; +import { findCallRecordingsByFilter } from 'src/logic-functions/data/find-call-recordings-by-filter.util'; +import { + updateCallRecording, + type CallRecordingUpdateFields, +} from 'src/logic-functions/data/update-call-recording.util'; +import { isCallRecordingStatusDowngrade } from 'src/logic-functions/domain/is-call-recording-status-downgrade.util'; +import { mapMeetingBaasStatusToCallRecordingStatus } from 'src/logic-functions/domain/map-meeting-baas-status-to-call-recording-status.util'; +import { ingestCallRecordingMediaFromUrls } from 'src/logic-functions/flows/ingest-call-recording-media.util'; +import { + parseMeetingBaasWebhookEvent, + type MeetingBaasWebhookBody, + type MeetingBaasWebhookEvent, +} from 'src/logic-functions/meeting-baas-api/parse-meeting-baas-webhook-event.util'; +import { normalizeMeetingBaasTranscript } from 'src/logic-functions/meeting-baas-api/normalize-meeting-baas-transcript.util'; +import { type CallRecordingRecord } from 'src/logic-functions/types/call-recording-record.type'; + +type MeetingBaasWebhookHandlerResult = + | { status: 'updated'; callRecordingId: string } + | { status: 'skipped'; reason: string }; + +export const handleMeetingBaasWebhook = async ({ + client, + body, +}: { + client: CoreApiClient; + body: MeetingBaasWebhookBody; +}): Promise => { + const webhookEvent = parseMeetingBaasWebhookEvent(body); + + if (isUndefined(webhookEvent)) { + return { status: 'skipped', reason: 'unsupported Meeting BaaS payload' }; + } + + const callRecording = await findMatchingCallRecording({ + client, + webhookEvent, + }); + + if (isUndefined(callRecording)) { + return { status: 'skipped', reason: 'no matching call recording' }; + } + + const status = mapEventToStatus(webhookEvent); + + if (isUndefined(status)) { + return { + status: 'skipped', + reason: `unsupported Meeting BaaS event ${webhookEvent.event}`, + }; + } + + if ( + isCallRecordingStatusDowngrade({ + fromStatus: callRecording.status, + toStatus: status, + }) + ) { + return { status: 'skipped', reason: 'stale status event' }; + } + + const updateData: CallRecordingUpdateFields = { + status, + ...(isUndefined(webhookEvent.externalBotId) + ? {} + : { externalBotId: webhookEvent.externalBotId }), + ...(isUndefined(webhookEvent.externalRecordingId) + ? {} + : { externalRecordingId: webhookEvent.externalRecordingId }), + ...buildRecordingTimestampsUpdate({ callRecording, webhookEvent }), + ...(status === CallRecordingStatus.FAILED + ? { callRecorderFailureReason: webhookEvent.failureReason ?? null } + : {}), + }; + + if (status === CallRecordingStatus.COMPLETED) { + Object.assign( + updateData, + await ingestCallRecordingMediaFromUrls({ + callRecordingId: callRecording.id, + hasAudio: false, + hasVideo: false, + audioUrl: webhookEvent.audioUrl, + videoUrl: webhookEvent.videoUrl, + }), + ); + + const transcript = await resolveTranscript(webhookEvent); + + if (!isUndefined(transcript)) { + updateData.transcript = normalizeTranscriptUpdate(transcript); + } + } + + await updateCallRecording(client, { + id: callRecording.id, + data: updateData, + }); + + return { status: 'updated', callRecordingId: callRecording.id }; +}; + +const findMatchingCallRecording = async ({ + client, + webhookEvent, +}: { + client: CoreApiClient; + webhookEvent: MeetingBaasWebhookEvent; +}): Promise => { + if (!isUndefined(webhookEvent.callRecordingIdFromMetadata)) { + return ( + await findCallRecordingsByFilter(client, { + id: { eq: webhookEvent.callRecordingIdFromMetadata }, + }) + )[0]; + } + + if (isUndefined(webhookEvent.externalBotId)) { + return undefined; + } + + return ( + await findCallRecordingsByFilter(client, { + externalBotId: { eq: webhookEvent.externalBotId }, + }) + )[0]; +}; + +const mapEventToStatus = ( + webhookEvent: MeetingBaasWebhookEvent, +): CallRecordingStatus | undefined => { + if (webhookEvent.event === 'bot.completed') { + return CallRecordingStatus.COMPLETED; + } + + if (webhookEvent.event === 'bot.failed') { + return CallRecordingStatus.FAILED; + } + + return mapMeetingBaasStatusToCallRecordingStatus(webhookEvent.status); +}; + +const buildRecordingTimestampsUpdate = ({ + callRecording, + webhookEvent, +}: { + callRecording: CallRecordingRecord; + webhookEvent: MeetingBaasWebhookEvent; +}): Pick => ({ + ...(isUndefined(callRecording.startedAt) && + !isUndefined(webhookEvent.startedAt) + ? { startedAt: webhookEvent.startedAt } + : {}), + ...(isUndefined(callRecording.endedAt) && !isUndefined(webhookEvent.endedAt) + ? { endedAt: webhookEvent.endedAt } + : {}), +}); + +const resolveTranscript = async ( + webhookEvent: MeetingBaasWebhookEvent, +): Promise => { + if (!isUndefined(webhookEvent.transcript)) { + return webhookEvent.transcript; + } + + const transcriptUrl = + webhookEvent.transcriptionUrl ?? webhookEvent.rawTranscriptionUrl; + + if (isUndefined(transcriptUrl)) { + return undefined; + } + + try { + const response = await fetch(transcriptUrl, { + signal: AbortSignal.timeout(120_000), + }); + + if (!response.ok) { + return undefined; + } + + return await response.json(); + } catch { + return undefined; + } +}; + +const normalizeTranscriptUpdate = ( + transcript: unknown, +): Record => + normalizeMeetingBaasTranscript(transcript) as Record; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/ingest-call-recording-media.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/ingest-call-recording-media.util.ts index f7e28399a7..ad2dc05f57 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/ingest-call-recording-media.util.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/ingest-call-recording-media.util.ts @@ -76,6 +76,59 @@ export const ingestCallRecordingMedia = async ({ return updateFields; }; +export const ingestCallRecordingMediaFromUrls = async ({ + callRecordingId, + hasAudio, + hasVideo, + audioUrl, + videoUrl, +}: { + callRecordingId: string; + hasAudio: boolean; + hasVideo: boolean; + audioUrl: string | undefined; + videoUrl: string | undefined; +}): Promise => { + if (hasAudio && hasVideo) { + return {}; + } + + const metadataClient = new MetadataApiClient(); + const updateFields: CallRecordingMediaUpdateFields = {}; + + if (!hasVideo && !isUndefined(videoUrl)) { + const video = await ingestMediaArtifact({ + callRecordingId, + metadataClient, + url: videoUrl, + fileName: 'video.mp4', + fieldMetadataUniversalIdentifier: + CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER, + }); + + if (!isUndefined(video)) { + updateFields.video = video; + } + } + + if (!hasAudio && !isUndefined(audioUrl)) { + const audio = await ingestMediaArtifact({ + callRecordingId, + metadataClient, + url: audioUrl, + fileName: 'audio.mp3', + fieldMetadataUniversalIdentifier: + CALL_RECORDING_AUDIO_FIELD_UNIVERSAL_IDENTIFIER, + }); + + if (!isUndefined(audio)) { + updateFields.audio = audio; + } + } + + return updateFields; +}; + const ingestMediaArtifact = async ({ callRecordingId, metadataClient, diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/reap-orphaned-call-recorders.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/reap-orphaned-call-recorders.util.ts index 42445a3aa7..2396282dec 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/reap-orphaned-call-recorders.util.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/reap-orphaned-call-recorders.util.ts @@ -9,6 +9,7 @@ import { findCallRecordingsByIds } from 'src/logic-functions/data/find-call-reco import { getCurrentWorkspaceId } from 'src/logic-functions/data/get-current-workspace-id.util'; import { getUniqueSortedIds } from 'src/logic-functions/utils/get-unique-sorted-ids.util'; import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; +import { getCallRecorderProvider } from 'src/logic-functions/providers/get-call-recorder-provider.util'; import { listScheduledRecallBots, type RecallScheduledBot, @@ -29,6 +30,10 @@ export const reapOrphanedCallRecorders = async ({ joinAtAfter: string; joinAtBefore: string; }): Promise => { + if (getCallRecorderProvider() !== 'recall') { + return { scannedBotCount: 0, canceledExternalBotIds: [] }; + } + const listResult = await listScheduledRecallBots({ joinAtAfter, joinAtBefore, diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/reschedule-call-recording-bot.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/reschedule-call-recording-bot.util.ts index caa1e8801b..6901e07803 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/reschedule-call-recording-bot.util.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/reschedule-call-recording-bot.util.ts @@ -5,7 +5,7 @@ import { type MeetingRecording } from 'src/logic-functions/types/meeting-recordi import { buildRecallBotMetadata } from 'src/logic-functions/domain/build-recall-bot-metadata.util'; import { computeRecallBotJoinAt } from 'src/logic-functions/domain/compute-recall-bot-join-at.util'; import { getCurrentWorkspaceId } from 'src/logic-functions/data/get-current-workspace-id.util'; -import { rescheduleRecallBot } from 'src/logic-functions/recall-api/reschedule-recall-bot.util'; +import { rescheduleCallRecorderBot } from 'src/logic-functions/providers/reschedule-call-recorder-bot.util'; import { updateCallRecording } from 'src/logic-functions/data/update-call-recording.util'; const RECALL_BOT_NOT_FOUND_STATUS = 404; @@ -33,13 +33,13 @@ export const rescheduleCallRecordingBot = async ( if (isUndefined(workspaceId)) { console.warn( - `[call-recorder] cannot reschedule Recall bot for callRecording ${callRecording.id}: workspace id unavailable`, + `[call-recorder] cannot reschedule provider bot for callRecording ${callRecording.id}: workspace id unavailable`, ); return; } - const rescheduleResult = await rescheduleRecallBot({ + const rescheduleResult = await rescheduleCallRecorderBot({ externalBotId, meetingUrl, joinAt, @@ -51,6 +51,13 @@ export const rescheduleCallRecordingBot = async ( }); if (rescheduleResult.ok) { + if (rescheduleResult.externalBotId !== externalBotId) { + await updateCallRecording(client, { + id: callRecording.id, + data: { externalBotId: rescheduleResult.externalBotId }, + }); + } + return; } @@ -65,6 +72,6 @@ export const rescheduleCallRecordingBot = async ( } console.warn( - `[call-recorder] failed to update Recall bot for callRecording ${callRecording.id}: ${rescheduleResult.errorMessage}`, + `[call-recorder] failed to update provider bot for callRecording ${callRecording.id}: ${rescheduleResult.errorMessage}`, ); }; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/cancel-meeting-baas-bot.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/cancel-meeting-baas-bot.util.ts new file mode 100644 index 0000000000..5e60e521da --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/cancel-meeting-baas-bot.util.ts @@ -0,0 +1,24 @@ +import { getMeetingBaasApiConfig } from 'src/logic-functions/meeting-baas-api/get-meeting-baas-api-config.util'; +import { meetingBaasApiRequest } from 'src/logic-functions/meeting-baas-api/meeting-baas-api-request.util'; +import { type RecallBotRemovalResult } from 'src/logic-functions/types/recall-bot-operation-result.type'; + +export const cancelMeetingBaasBot = async ({ + externalBotId, +}: { + externalBotId: string; +}): Promise => { + const configResult = getMeetingBaasApiConfig(); + + if (!configResult.success) { + return { ok: false, status: null, errorMessage: configResult.error }; + } + + const result = await meetingBaasApiRequest({ + config: configResult.config, + path: `/v2/bots/scheduled/${externalBotId}`, + method: 'DELETE', + allowNotFound: true, + }); + + return result.ok ? { ok: true } : result; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/get-meeting-baas-api-config.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/get-meeting-baas-api-config.util.ts new file mode 100644 index 0000000000..91892d0812 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/get-meeting-baas-api-config.util.ts @@ -0,0 +1,80 @@ +import { CALL_RECORDER_NAME_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-name-env-var-name'; +import { DEFAULT_CALL_RECORDER_NAME } from 'src/logic-functions/constants/default-call-recorder-name'; +import { MEETING_BAAS_API_BASE_URL_ENV_VAR_NAME } from 'src/logic-functions/constants/meeting-baas-api-base-url-env-var-name'; +import { MEETING_BAAS_API_KEY_ENV_VAR_NAME } from 'src/logic-functions/constants/meeting-baas-api-key-env-var-name'; +import { MEETING_BAAS_CALLBACK_SECRET_ENV_VAR_NAME } from 'src/logic-functions/constants/meeting-baas-callback-secret-env-var-name'; +import { MEETING_BAAS_CALLBACK_URL_ENV_VAR_NAME } from 'src/logic-functions/constants/meeting-baas-callback-url-env-var-name'; +import { getApplicationVariableValue } from 'src/logic-functions/utils/get-application-variable-value.util'; +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; + +export type MeetingBaasApiConfig = { + apiKey: string; + baseUrl: string; + botName: string; + callbackUrl: string; + callbackSecret: string; +}; + +export const getMeetingBaasApiConfig = (): + | { success: true; config: MeetingBaasApiConfig } + | { success: false; error: string } => { + const apiKey = getApplicationVariableValue(MEETING_BAAS_API_KEY_ENV_VAR_NAME); + + if (!isNonEmptyString(apiKey)) { + return { + success: false, + error: + 'MEETING_BAAS_API_KEY server variable is not set. Set it when CALL_RECORDER_PROVIDER is meeting-baas.', + }; + } + + const callbackUrl = getApplicationVariableValue( + MEETING_BAAS_CALLBACK_URL_ENV_VAR_NAME, + ); + + if (!isNonEmptyString(callbackUrl)) { + return { + success: false, + error: + 'MEETING_BAAS_CALLBACK_URL server variable is not set. Set it to the absolute meeting-baas-webhook URL.', + }; + } + + const callbackSecret = getApplicationVariableValue( + MEETING_BAAS_CALLBACK_SECRET_ENV_VAR_NAME, + ); + + if (!isNonEmptyString(callbackSecret)) { + return { + success: false, + error: + 'MEETING_BAAS_CALLBACK_SECRET server variable is not set. It must match x-mb-secret.', + }; + } + + const rawBaseUrl = getApplicationVariableValue( + MEETING_BAAS_API_BASE_URL_ENV_VAR_NAME, + ); + const rawBotName = getApplicationVariableValue(CALL_RECORDER_NAME_ENV_VAR_NAME); + + return { + success: true, + config: { + apiKey, + baseUrl: normalizeBaseUrl(rawBaseUrl), + botName: isNonEmptyString(rawBotName) + ? rawBotName.trim() + : DEFAULT_CALL_RECORDER_NAME, + callbackUrl: callbackUrl.trim(), + callbackSecret, + }, + }; +}; + +const normalizeBaseUrl = (rawBaseUrl: string | undefined): string => { + const baseUrl = isNonEmptyString(rawBaseUrl) + ? rawBaseUrl.trim() + : 'https://api.meetingbaas.com'; + + return baseUrl.replace(/\/+$/, ''); +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/get-meeting-baas-bot.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/get-meeting-baas-bot.util.ts new file mode 100644 index 0000000000..e2fb06b6a6 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/get-meeting-baas-bot.util.ts @@ -0,0 +1,33 @@ +import { getMeetingBaasApiConfig } from 'src/logic-functions/meeting-baas-api/get-meeting-baas-api-config.util'; +import { meetingBaasApiRequest } from 'src/logic-functions/meeting-baas-api/meeting-baas-api-request.util'; + +export type MeetingBaasBotDetails = Record; + +export const getMeetingBaasBot = async ({ + externalBotId, +}: { + externalBotId: string; +}): Promise< + | { ok: true; bot: MeetingBaasBotDetails } + | { ok: false; status: number | null; errorMessage: string } +> => { + const configResult = getMeetingBaasApiConfig(); + + if (!configResult.success) { + return { ok: false, status: null, errorMessage: configResult.error }; + } + + const result = await meetingBaasApiRequest<{ + data?: MeetingBaasBotDetails; + }>({ + config: configResult.config, + path: `/v2/bots/${externalBotId}`, + method: 'GET', + }); + + if (!result.ok) { + return result; + } + + return { ok: true, bot: result.data?.data ?? {} }; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/meeting-baas-api-request.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/meeting-baas-api-request.util.ts new file mode 100644 index 0000000000..cea4a26ec0 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/meeting-baas-api-request.util.ts @@ -0,0 +1,99 @@ +import { type MeetingBaasApiConfig } from 'src/logic-functions/meeting-baas-api/get-meeting-baas-api-config.util'; + +type MeetingBaasApiRequestArgs = { + config: MeetingBaasApiConfig; + path: string; + method: 'GET' | 'POST' | 'PATCH' | 'DELETE'; + body?: Record; + allowNotFound?: boolean; +}; + +export type MeetingBaasApiRequestFailure = { + ok: false; + status: number | null; + errorMessage: string; +}; + +export const meetingBaasApiRequest = async ({ + config, + path, + method, + body, + allowNotFound = false, +}: MeetingBaasApiRequestArgs): Promise< + | { ok: true; data: TData | undefined; status: number } + | MeetingBaasApiRequestFailure +> => { + try { + const response = await fetch(`${config.baseUrl}${path}`, { + method, + headers: { + 'content-type': 'application/json', + 'x-meeting-baas-api-key': config.apiKey, + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + + if (allowNotFound && response.status === 404) { + return { ok: true, data: undefined, status: response.status }; + } + + if (!response.ok) { + return { + ok: false, + status: response.status, + errorMessage: await extractMeetingBaasErrorMessage(response), + }; + } + + if (response.status === 204) { + return { ok: true, data: undefined, status: response.status }; + } + + return { + ok: true, + data: (await response.json()) as TData, + status: response.status, + }; + } catch (error) { + return { + ok: false, + status: null, + errorMessage: `Meeting BaaS API request failed: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } +}; + +const extractMeetingBaasErrorMessage = async ( + response: Response, +): Promise => { + const fallback = `Meeting BaaS API responded with HTTP ${response.status}`; + + try { + const body = (await response.json()) as unknown; + + if ( + typeof body === 'object' && + body !== null && + 'error' in body && + typeof body.error === 'string' + ) { + return body.error; + } + + if ( + typeof body === 'object' && + body !== null && + 'message' in body && + typeof body.message === 'string' + ) { + return body.message; + } + } catch { + return fallback; + } + + return fallback; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/normalize-meeting-baas-transcript.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/normalize-meeting-baas-transcript.util.ts new file mode 100644 index 0000000000..dd8ca6b99b --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/normalize-meeting-baas-transcript.util.ts @@ -0,0 +1,109 @@ +import { isArray, isNumber, isUndefined } from '@sniptt/guards'; + +import { asRecord } from 'src/logic-functions/utils/as-record.util'; +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; + +type CallRecorderTranscript = Array<{ + participant: { name: string }; + words: Array<{ + text: string; + start_timestamp?: { relative: number }; + end_timestamp?: { relative: number }; + }>; +}>; + +export const normalizeMeetingBaasTranscript = ( + transcript: unknown, +): unknown => { + const transcriptRecord = asRecord(transcript); + const utterances = + readUtterances(transcriptRecord?.result) ?? + readUtterances(transcriptRecord?.transcription) ?? + (isArray(transcript) ? transcript : undefined); + + if (isUndefined(utterances)) { + return transcript; + } + + const normalizedEntries = utterances + .map(asRecord) + .filter((utterance): utterance is Record => + !isUndefined(utterance), + ) + .map(normalizeUtterance) + .filter( + (entry): entry is CallRecorderTranscript[number] => + !isUndefined(entry), + ); + + return normalizedEntries.length > 0 ? normalizedEntries : transcript; +}; + +const readUtterances = (container: unknown): unknown[] | undefined => { + const record = asRecord(container); + + return isArray(record?.utterances) ? record.utterances : undefined; +}; + +const normalizeUtterance = ( + utterance: Record, +): CallRecorderTranscript[number] | undefined => { + const words = isArray(utterance.words) + ? utterance.words + .map(asRecord) + .filter((word): word is Record => !isUndefined(word)) + .map(normalizeWord) + .filter((word): word is CallRecorderTranscript[number]['words'][number] => + !isUndefined(word), + ) + : []; + + if (words.length === 0 && isNonEmptyString(utterance.text)) { + words.push({ + text: utterance.text.trim(), + ...buildTimestamp('start_timestamp', utterance.start), + ...buildTimestamp('end_timestamp', utterance.end), + }); + } + + if (words.length === 0) { + return undefined; + } + + return { + participant: { + name: isNonEmptyString(utterance.speaker) + ? utterance.speaker.trim() + : 'Unknown speaker', + }, + words, + }; +}; + +const normalizeWord = ( + word: Record, +): CallRecorderTranscript[number]['words'][number] | undefined => { + const text = isNonEmptyString(word.word) + ? word.word + : isNonEmptyString(word.text) + ? word.text + : undefined; + + if (isUndefined(text)) { + return undefined; + } + + return { + text: text.trim(), + ...buildTimestamp('start_timestamp', word.start), + ...buildTimestamp('end_timestamp', word.end), + }; +}; + +const buildTimestamp = ( + key: 'start_timestamp' | 'end_timestamp', + value: unknown, +): Partial => + isNumber(value) && Number.isFinite(value) + ? { [key]: { relative: value } } + : {}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/parse-meeting-baas-webhook-event.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/parse-meeting-baas-webhook-event.util.ts new file mode 100644 index 0000000000..1f369f246e --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/parse-meeting-baas-webhook-event.util.ts @@ -0,0 +1,67 @@ +import { isUndefined } from '@sniptt/guards'; + +import { asRecord } from 'src/logic-functions/utils/as-record.util'; +import { getString } from 'src/logic-functions/utils/get-string.util'; +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; + +export type MeetingBaasWebhookBody = Record; + +export type MeetingBaasWebhookEvent = { + event: string; + externalBotId: string | undefined; + status: string | undefined; + callRecordingIdFromMetadata: string | undefined; + externalRecordingId: string | undefined; + startedAt: string | undefined; + endedAt: string | undefined; + audioUrl: string | undefined; + videoUrl: string | undefined; + transcriptionUrl: string | undefined; + rawTranscriptionUrl: string | undefined; + transcript: unknown; + failureReason: string | undefined; +}; + +export const parseMeetingBaasWebhookEvent = ( + body: MeetingBaasWebhookBody, +): MeetingBaasWebhookEvent | undefined => { + const event = getString(body.event); + + if (isUndefined(event)) { + return undefined; + } + + const data = asRecord(body.data) ?? body; + const extra = asRecord(data.extra) ?? asRecord(body.extra); + + return { + event, + externalBotId: getString(data.bot_id), + status: getString(data.status), + callRecordingIdFromMetadata: readMetadataString( + extra, + 'twentyCallRecordingId', + ), + externalRecordingId: getString(data.bot_id), + startedAt: getString(data.joined_at), + endedAt: getString(data.exited_at), + audioUrl: getString(data.audio), + videoUrl: getString(data.video), + transcriptionUrl: getString(data.transcription), + rawTranscriptionUrl: getString(data.raw_transcription), + transcript: data.transcript ?? data.transcription_content, + failureReason: + getString(data.error_message) ?? + getString(data.error_code) ?? + getString(data.status), + }; +}; + +const readMetadataString = ( + metadata: Record | undefined, + key: string, +): string | undefined => { + const value = metadata?.[key]; + + return isNonEmptyString(value) ? value.trim() : undefined; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/reschedule-meeting-baas-bot.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/reschedule-meeting-baas-bot.util.ts new file mode 100644 index 0000000000..9e54ec1227 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/reschedule-meeting-baas-bot.util.ts @@ -0,0 +1,52 @@ +import { + scheduleMeetingBaasBot, + type ScheduleMeetingBaasBotArgs, +} from 'src/logic-functions/meeting-baas-api/schedule-meeting-baas-bot.util'; +import { getMeetingBaasApiConfig } from 'src/logic-functions/meeting-baas-api/get-meeting-baas-api-config.util'; +import { meetingBaasApiRequest } from 'src/logic-functions/meeting-baas-api/meeting-baas-api-request.util'; +import { type RecallBotScheduleResult } from 'src/logic-functions/types/recall-bot-operation-result.type'; + +type RescheduleMeetingBaasBotArgs = ScheduleMeetingBaasBotArgs & { + externalBotId: string; +}; + +export const rescheduleMeetingBaasBot = async ({ + externalBotId, + meetingUrl, + joinAt, + metadata, +}: RescheduleMeetingBaasBotArgs): Promise => { + const configResult = getMeetingBaasApiConfig(); + + if (!configResult.success) { + return { ok: false, status: null, errorMessage: configResult.error }; + } + + const result = await meetingBaasApiRequest({ + config: configResult.config, + path: `/v2/bots/scheduled/${externalBotId}`, + method: 'PATCH', + body: { + meeting_url: meetingUrl, + join_at: joinAt, + bot_name: configResult.config.botName, + callback_enabled: true, + callback_config: { + url: configResult.config.callbackUrl, + secret: configResult.config.callbackSecret, + method: 'POST', + }, + extra: metadata, + }, + }); + + if (result.ok) { + return { ok: true, externalBotId }; + } + + if (result.status === 404) { + return scheduleMeetingBaasBot({ meetingUrl, joinAt, metadata }); + } + + return result; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/schedule-meeting-baas-bot.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/schedule-meeting-baas-bot.util.ts new file mode 100644 index 0000000000..8212e5f132 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-api/schedule-meeting-baas-bot.util.ts @@ -0,0 +1,104 @@ +import { isUndefined } from '@sniptt/guards'; + +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_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_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 { getApplicationVariableValue } from 'src/logic-functions/utils/get-application-variable-value.util'; +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; +import { getMeetingBaasApiConfig } from 'src/logic-functions/meeting-baas-api/get-meeting-baas-api-config.util'; +import { meetingBaasApiRequest } from 'src/logic-functions/meeting-baas-api/meeting-baas-api-request.util'; +import { type RecallBotMetadata } from 'src/logic-functions/types/recall-bot-metadata.type'; +import { type RecallBotScheduleResult } from 'src/logic-functions/types/recall-bot-operation-result.type'; + +type MeetingBaasScheduledBotResponse = { + data?: { + bot_id?: string; + }; +}; + +export type ScheduleMeetingBaasBotArgs = { + meetingUrl: string; + joinAt: string; + metadata: RecallBotMetadata; +}; + +export const scheduleMeetingBaasBot = async ({ + meetingUrl, + joinAt, + metadata, +}: ScheduleMeetingBaasBotArgs): Promise => { + const configResult = getMeetingBaasApiConfig(); + + if (!configResult.success) { + return { ok: false, status: null, errorMessage: configResult.error }; + } + + const result = await meetingBaasApiRequest({ + config: configResult.config, + path: '/v2/bots/scheduled', + method: 'POST', + body: { + meeting_url: meetingUrl, + join_at: joinAt, + bot_name: configResult.config.botName, + recording_mode: 'speaker_view', + transcription_enabled: true, + callback_enabled: true, + callback_config: { + url: configResult.config.callbackUrl, + secret: configResult.config.callbackSecret, + method: 'POST', + }, + timeout_config: { + waiting_room_timeout: getPositiveIntegerVariable( + CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS_ENV_VAR_NAME, + CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS, + ), + no_one_joined_timeout: getPositiveIntegerVariable( + CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS_ENV_VAR_NAME, + CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS, + ), + silence_timeout: getPositiveIntegerVariable( + CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS_ENV_VAR_NAME, + CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS, + ), + }, + extra: metadata, + }, + }); + + if (!result.ok) { + return result; + } + + const externalBotId = result.data?.data?.bot_id; + + if (isUndefined(externalBotId)) { + return { + ok: false, + status: null, + errorMessage: + 'Meeting BaaS created a scheduled bot but did not return data.bot_id', + }; + } + + return { ok: true, externalBotId }; +}; + +const getPositiveIntegerVariable = ( + envVarName: string, + fallback: number, +): number => { + const rawValue = getApplicationVariableValue(envVarName); + + if (!isNonEmptyString(rawValue)) { + return fallback; + } + + const parsed = Number(rawValue.trim()); + + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-webhook.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-webhook.ts new file mode 100644 index 0000000000..7d97303b61 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/meeting-baas-webhook.ts @@ -0,0 +1,61 @@ +import { isNull, isUndefined } from '@sniptt/guards'; +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define'; +import { Response } from 'twenty-sdk/logic-function'; + +import { MEETING_BAAS_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/meeting-baas-webhook-logic-function-universal-identifier'; +import { MEETING_BAAS_CALLBACK_SECRET_ENV_VAR_NAME } from 'src/logic-functions/constants/meeting-baas-callback-secret-env-var-name'; +import { handleMeetingBaasWebhook } from 'src/logic-functions/flows/handle-meeting-baas-webhook.util'; +import { type MeetingBaasWebhookBody } from 'src/logic-functions/meeting-baas-api/parse-meeting-baas-webhook-event.util'; +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 rejectWebhook = (status: number, error: string): Response => { + console.error(`[call-recorder] Meeting BaaS webhook rejected: ${error}`); + + return new Response({ error }, { status }); +}; + +export const meetingBaasWebhookRouteHandler = async ( + routePayload: RoutePayload, +): Promise => { + const webhookSecret = getApplicationVariableValue( + MEETING_BAAS_CALLBACK_SECRET_ENV_VAR_NAME, + ); + + if (!isNonEmptyString(webhookSecret)) { + return rejectWebhook( + 500, + 'MEETING_BAAS_CALLBACK_SECRET server variable is not set.', + ); + } + + if (routePayload.headers['x-mb-secret'] !== webhookSecret) { + return rejectWebhook(401, 'Invalid Meeting BaaS callback secret'); + } + + if (isUndefined(routePayload.body) || isNull(routePayload.body)) { + return rejectWebhook(400, 'Webhook payload was empty'); + } + + return handleMeetingBaasWebhook({ + client: new CoreApiClient(), + body: routePayload.body, + }); +}; + +export default defineLogicFunction({ + universalIdentifier: MEETING_BAAS_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, + name: 'meeting-baas-webhook', + description: + 'Receives Meeting BaaS webhook events and updates the matching CallRecording lifecycle status.', + timeoutSeconds: 30, + handler: meetingBaasWebhookRouteHandler, + serverWebhookTriggerSettings: { + workspaceIdResolver: { + source: 'body', + path: 'data.extra.twentyWorkspaceId', + }, + forwardedRequestHeaders: ['x-mb-secret'], + }, +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/providers/call-recorder-provider.type.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/providers/call-recorder-provider.type.ts new file mode 100644 index 0000000000..f59c72fd34 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/providers/call-recorder-provider.type.ts @@ -0,0 +1 @@ +export type CallRecorderProvider = 'recall' | 'meeting-baas'; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/providers/cancel-call-recorder-bot.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/providers/cancel-call-recorder-bot.util.ts new file mode 100644 index 0000000000..e679902cfa --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/providers/cancel-call-recorder-bot.util.ts @@ -0,0 +1,16 @@ +import { cancelMeetingBaasBot } from 'src/logic-functions/meeting-baas-api/cancel-meeting-baas-bot.util'; +import { cancelRecallBot } from 'src/logic-functions/recall-api/cancel-recall-bot.util'; +import { type RecallBotRemovalResult } from 'src/logic-functions/types/recall-bot-operation-result.type'; +import { getCallRecorderProvider } from 'src/logic-functions/providers/get-call-recorder-provider.util'; + +export const cancelCallRecorderBot = async ({ + externalBotId, +}: { + externalBotId: string; +}): Promise => { + if (getCallRecorderProvider() === 'meeting-baas') { + return cancelMeetingBaasBot({ externalBotId }); + } + + return cancelRecallBot({ externalBotId }); +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/providers/get-call-recorder-provider.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/providers/get-call-recorder-provider.util.ts new file mode 100644 index 0000000000..8ae75fb4ca --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/providers/get-call-recorder-provider.util.ts @@ -0,0 +1,11 @@ +import { CALL_RECORDER_PROVIDER_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-provider-env-var-name'; +import { type CallRecorderProvider } from 'src/logic-functions/providers/call-recorder-provider.type'; +import { getApplicationVariableValue } from 'src/logic-functions/utils/get-application-variable-value.util'; + +export const getCallRecorderProvider = (): CallRecorderProvider => { + const rawProvider = getApplicationVariableValue( + CALL_RECORDER_PROVIDER_ENV_VAR_NAME, + ); + + return rawProvider === 'meeting-baas' ? 'meeting-baas' : 'recall'; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/providers/reschedule-call-recorder-bot.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/providers/reschedule-call-recorder-bot.util.ts new file mode 100644 index 0000000000..7d5bc4855b --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/providers/reschedule-call-recorder-bot.util.ts @@ -0,0 +1,33 @@ +import { rescheduleMeetingBaasBot } from 'src/logic-functions/meeting-baas-api/reschedule-meeting-baas-bot.util'; +import { rescheduleRecallBot } from 'src/logic-functions/recall-api/reschedule-recall-bot.util'; +import { type RecallBotMetadata } from 'src/logic-functions/types/recall-bot-metadata.type'; +import { type RecallBotScheduleResult } from 'src/logic-functions/types/recall-bot-operation-result.type'; +import { getCallRecorderProvider } from 'src/logic-functions/providers/get-call-recorder-provider.util'; + +export const rescheduleCallRecorderBot = async ({ + externalBotId, + meetingUrl, + joinAt, + metadata, +}: { + externalBotId: string; + meetingUrl: string; + joinAt: string; + metadata: RecallBotMetadata; +}): Promise => { + if (getCallRecorderProvider() === 'meeting-baas') { + return rescheduleMeetingBaasBot({ + externalBotId, + meetingUrl, + joinAt, + metadata, + }); + } + + return rescheduleRecallBot({ + externalBotId, + meetingUrl, + joinAt, + metadata, + }); +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/providers/schedule-call-recorder-bot.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/providers/schedule-call-recorder-bot.util.ts new file mode 100644 index 0000000000..f5bdbc14c8 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/providers/schedule-call-recorder-bot.util.ts @@ -0,0 +1,21 @@ +import { scheduleMeetingBaasBot } from 'src/logic-functions/meeting-baas-api/schedule-meeting-baas-bot.util'; +import { scheduleRecallBot } from 'src/logic-functions/recall-api/schedule-recall-bot.util'; +import { type RecallBotMetadata } from 'src/logic-functions/types/recall-bot-metadata.type'; +import { type RecallBotScheduleResult } from 'src/logic-functions/types/recall-bot-operation-result.type'; +import { getCallRecorderProvider } from 'src/logic-functions/providers/get-call-recorder-provider.util'; + +export const scheduleCallRecorderBot = async ({ + meetingUrl, + joinAt, + metadata, +}: { + meetingUrl: string; + joinAt: string; + metadata: RecallBotMetadata; +}): Promise => { + if (getCallRecorderProvider() === 'meeting-baas') { + return scheduleMeetingBaasBot({ meetingUrl, joinAt, metadata }); + } + + return scheduleRecallBot({ meetingUrl, joinAt, metadata }); +};