diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/README.md b/packages/twenty-apps/internal/twenty-meeting-bot/README.md index 4728d9ffa0..1c7028611d 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/README.md +++ b/packages/twenty-apps/internal/twenty-meeting-bot/README.md @@ -15,6 +15,26 @@ Run `yarn twenty help` to list all available commands. - `yarn twenty docker:start` - Start the local Twenty server - `yarn test` - Run integration tests +## Recall.ai configuration + +This app schedules Recall.ai meeting bots and ingests their lifecycle events. A server admin configures it through server variables on the application registration (Settings → Applications → Twenty Meeting Bot): + +| Server variable | Required | Purpose | +| --- | --- | --- | +| `RECALL_API_KEY` | Yes | Recall.ai API key for the configured region; used to schedule, update, and cancel bots. | +| `RECALL_REGION` | No | Recall.ai region for API requests. Defaults to `eu-central-1`. | +| `RECALL_WEBHOOK_SECRET` | Yes | Svix signing secret (`whsec_…`) used to verify incoming Recall webhooks. | + +### Configuring the webhook + +The app exposes an unauthenticated route, `POST /webhook/recall`, that verifies the Recall/Svix signature and updates the matching `CallRecording`'s lifecycle status (`JOINING` → `RECORDING` → `PROCESSING`, or `FAILED_UNKNOWN`). + +1. In the Recall.ai dashboard, create a webhook endpoint (Status Change Webhooks) pointing at the public URL of this app's `POST /webhook/recall` route. +2. Copy the endpoint's signing secret — it starts with `whsec_`. +3. Set it as the `RECALL_WEBHOOK_SECRET` server variable on the Twenty Meeting Bot application registration. + +The handler ignores out-of-order or duplicate deliveries (it never moves a recording's status backwards) and returns a non-2xx response on signature failures so Recall retries. + ## Learn More - [Twenty Apps documentation](https://docs.twenty.com/developers/extend/apps/getting-started/quick-start) diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/application-config.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/application-config.ts index 514c3dacad..dc6c252918 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/application-config.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/application-config.ts @@ -9,6 +9,7 @@ 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_BOT_NAME_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-bot-name-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'; export default defineApplication({ universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, @@ -31,8 +32,14 @@ export default defineApplication({ isRequired: true, }, [RECALL_REGION_ENV_VAR_NAME]: { - description: `Recall.ai region used for API requests. Defaults to ${DEFAULT_RECALL_REGION} when unset. Asia Pacific Tokyo is ap-northeast-1.`, + description: `Recall.ai region used for API requests. Defaults to ${DEFAULT_RECALL_REGION} when unset. Europe Frankfurt is eu-central-1.`, isSecret: false, }, + [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.', + isSecret: true, + isRequired: true, + }, }, }); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/recall-webhook-logic-function-universal-identifier.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/recall-webhook-logic-function-universal-identifier.ts new file mode 100644 index 0000000000..98e5e559be --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/recall-webhook-logic-function-universal-identifier.ts @@ -0,0 +1,2 @@ +export const RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = + '9215afe6-1497-4149-a49d-e608e239bbaf'; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/__tests__/recall-webhook.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/__tests__/recall-webhook.test.ts new file mode 100644 index 0000000000..313c796d6a --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/__tests__/recall-webhook.test.ts @@ -0,0 +1,152 @@ +import { createHmac } from 'crypto'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { recallWebhookRouteHandler } from 'src/logic-functions/recall-webhook'; + +const getApplicationVariableValueMock = vi.hoisted(() => vi.fn()); +const handleRecallWebhookMock = vi.hoisted(() => vi.fn()); + +vi.mock( + 'src/logic-functions/utils/get-application-variable-value.util', + () => ({ + getApplicationVariableValue: getApplicationVariableValueMock, + }), +); + +vi.mock('src/logic-functions/flows/handle-recall-webhook.util', () => ({ + handleRecallWebhook: handleRecallWebhookMock, +})); + +vi.mock('twenty-client-sdk/core', () => ({ + CoreApiClient: vi.fn(), +})); + +const SECRET_BYTES = Buffer.from('entry-test-secret'); +const SECRET = `whsec_${SECRET_BYTES.toString('base64')}`; + +type RecallWebhookRoutePayload = Parameters< + typeof recallWebhookRouteHandler +>[0]; + +const buildRoutePayload = ( + overrides: Partial, +): RecallWebhookRoutePayload => + ({ + headers: {}, + ...overrides, + }) as RecallWebhookRoutePayload; + +const buildSignedHeaders = (rawBody: string): Record => { + const webhookId = 'msg_entry_test'; + const webhookTimestamp = Math.floor(Date.now() / 1000).toString(); + const signature = createHmac('sha256', SECRET_BYTES) + .update(`${webhookId}.${webhookTimestamp}.${rawBody}`) + .digest('base64'); + + return { + 'webhook-id': webhookId, + 'webhook-timestamp': webhookTimestamp, + 'webhook-signature': `v1,${signature}`, + }; +}; + +describe('recallWebhookRouteHandler', () => { + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + getApplicationVariableValueMock.mockReset(); + getApplicationVariableValueMock.mockReturnValue(SECRET); + handleRecallWebhookMock.mockReset(); + handleRecallWebhookMock.mockResolvedValue({ status: 'updated' }); + }); + + it('responds 500 when the webhook secret is not configured', async () => { + getApplicationVariableValueMock.mockReturnValue(undefined); + + const result = await recallWebhookRouteHandler( + buildRoutePayload({ rawBody: '{}', body: {} }), + ); + + expect(result).toMatchObject({ + __twentyHttpResponse: true, + status: 500, + body: { + error: expect.stringContaining('RECALL_WEBHOOK_SECRET'), + }, + }); + }); + + it('responds 500 when the raw body is not forwarded', async () => { + const result = await recallWebhookRouteHandler( + buildRoutePayload({ body: {} }), + ); + + expect(result).toMatchObject({ + __twentyHttpResponse: true, + status: 500, + body: { + error: expect.stringContaining('Raw request body'), + }, + }); + }); + + it('responds 401 when the signature is invalid', async () => { + const result = await recallWebhookRouteHandler( + buildRoutePayload({ + rawBody: '{}', + body: {}, + headers: { + 'webhook-id': 'msg_entry_test', + 'webhook-timestamp': Math.floor(Date.now() / 1000).toString(), + 'webhook-signature': 'v1,not-a-real-signature', + }, + }), + ); + + expect(result).toMatchObject({ + __twentyHttpResponse: true, + status: 401, + body: { + error: expect.stringContaining('Invalid webhook signature'), + }, + }); + }); + + it('responds 400 when a correctly signed payload is empty', async () => { + const rawBody = 'null'; + + const result = await recallWebhookRouteHandler( + buildRoutePayload({ + rawBody, + body: null, + headers: buildSignedHeaders(rawBody), + }), + ); + + expect(result).toMatchObject({ + __twentyHttpResponse: true, + status: 400, + body: { + error: 'Webhook payload was empty', + }, + }); + }); + + it('dispatches a correctly signed payload to the handler', async () => { + const rawBody = JSON.stringify({ event: 'recording.done' }); + + const result = await recallWebhookRouteHandler( + buildRoutePayload({ + rawBody, + body: { event: 'recording.done' }, + headers: buildSignedHeaders(rawBody), + }), + ); + + expect(handleRecallWebhookMock).toHaveBeenCalledTimes(1); + expect(handleRecallWebhookMock).toHaveBeenCalledWith( + expect.objectContaining({ body: { event: 'recording.done' } }), + ); + expect(result).toEqual({ status: 'updated' }); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/default-recall-region.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/default-recall-region.ts index dd19a5e7a3..fdafca5053 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/default-recall-region.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/default-recall-region.ts @@ -1 +1 @@ -export const DEFAULT_RECALL_REGION = 'ap-northeast-1'; +export const DEFAULT_RECALL_REGION = 'eu-central-1'; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-webhook-secret-env-var-name.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-webhook-secret-env-var-name.ts new file mode 100644 index 0000000000..520dfd2ff1 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-webhook-secret-env-var-name.ts @@ -0,0 +1 @@ +export const RECALL_WEBHOOK_SECRET_ENV_VAR_NAME = 'RECALL_WEBHOOK_SECRET'; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/is-call-recording-status-downgrade.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/is-call-recording-status-downgrade.test.ts new file mode 100644 index 0000000000..8d43cedad9 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/is-call-recording-status-downgrade.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { isCallRecordingStatusDowngrade } from 'src/logic-functions/domain/is-call-recording-status-downgrade.util'; + +describe('isCallRecordingStatusDowngrade', () => { + it.each([ + ['SCHEDULED', 'JOINING', false], + ['JOINING', 'RECORDING', false], + ['RECORDING', 'PROCESSING', false], + ['PROCESSING', 'FAILED_UNKNOWN', false], + ['PROCESSING', 'COMPLETED', false], + ['RECORDING', 'RECORDING', false], + ['COMPLETED', 'RECORDING', true], + ['PROCESSING', 'JOINING', true], + ['FAILED_UNKNOWN', 'RECORDING', true], + ['JOINING', 'SCHEDULED', true], + ])('from %s to %s -> %s', (fromStatus, toStatus, expected) => { + expect(isCallRecordingStatusDowngrade({ fromStatus, toStatus })).toBe( + expected, + ); + }); + + it('never treats transitions from unknown statuses as downgrades', () => { + expect( + isCallRecordingStatusDowngrade({ + fromStatus: undefined, + toStatus: 'COMPLETED', + }), + ).toBe(false); + expect( + isCallRecordingStatusDowngrade({ + fromStatus: 'NOT_A_STATUS', + toStatus: 'SCHEDULED', + }), + ).toBe(false); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/is-call-recording-status-downgrade.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/is-call-recording-status-downgrade.util.ts new file mode 100644 index 0000000000..ad2371f4c9 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/is-call-recording-status-downgrade.util.ts @@ -0,0 +1,37 @@ +import { isUndefined } from '@sniptt/guards'; + +import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status'; + +// Deliveries are unordered; a late event must never move status backwards. +const CALL_RECORDING_STATUS_PROGRESSION: Record = { + [CallRecordingStatus.SCHEDULED]: 0, + [CallRecordingStatus.JOINING]: 1, + [CallRecordingStatus.RECORDING]: 2, + [CallRecordingStatus.PROCESSING]: 3, + [CallRecordingStatus.FAILED_UNKNOWN]: 4, + [CallRecordingStatus.COMPLETED]: 5, +}; + +const getCallRecordingStatusRank = (status: string): number | undefined => + status in CALL_RECORDING_STATUS_PROGRESSION + ? CALL_RECORDING_STATUS_PROGRESSION[status as CallRecordingStatus] + : undefined; + +export const isCallRecordingStatusDowngrade = ({ + fromStatus, + toStatus, +}: { + fromStatus: string | undefined; + toStatus: string; +}): boolean => { + const fromRank = isUndefined(fromStatus) + ? undefined + : getCallRecordingStatusRank(fromStatus); + const toRank = getCallRecordingStatusRank(toStatus); + + if (isUndefined(fromRank) || isUndefined(toRank)) { + return false; + } + + return toRank < fromRank; +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util.ts new file mode 100644 index 0000000000..ab3448e811 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util.ts @@ -0,0 +1,26 @@ +import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status'; + +export const mapRecallStatusCodeToCallRecordingStatus = ( + statusCode: string | undefined, +): CallRecordingStatus | undefined => { + switch (statusCode) { + case 'joining_call': + case 'in_waiting_room': + return CallRecordingStatus.JOINING; + case 'in_call_not_recording': + case 'recording_permission_allowed': + case 'in_call_recording': + return CallRecordingStatus.RECORDING; + // 'done' stays PROCESSING: COMPLETED is set only after all artifacts are ingested. + case 'call_ended': + case 'analysis_done': + case 'done': + return CallRecordingStatus.PROCESSING; + case 'fatal': + case 'analysis_failed': + case 'recording_permission_denied': + return CallRecordingStatus.FAILED_UNKNOWN; + default: + return undefined; + } +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts new file mode 100644 index 0000000000..8b9d7148d2 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts @@ -0,0 +1,669 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; +import { describe, expect, it } from 'vitest'; + +import { handleRecallWebhook } from 'src/logic-functions/flows/handle-recall-webhook.util'; + +type CallRecordingNode = { + id: string; + status?: string | null; + externalBotId?: string | null; + externalRecordingId?: string | null; + startedAt?: string | null; + endedAt?: string | null; +}; + +class FakeCoreApiClient { + callRecordings: CallRecordingNode[]; + mutations: Array<{ id: string; data: Record }> = []; + + constructor(callRecordings: CallRecordingNode[]) { + this.callRecordings = callRecordings; + } + + async query(query: any): Promise { + if (query.callRecordings !== undefined) { + const filter = query.callRecordings.__args.filter; + + return { + callRecordings: { + edges: this.filterCallRecordings(filter).map((callRecording) => ({ + node: callRecording, + })), + }, + }; + } + + throw new Error(`Unhandled query: ${JSON.stringify(query)}`); + } + + async mutation(mutation: any): Promise { + if (mutation.updateCallRecording !== undefined) { + const { id, data } = mutation.updateCallRecording.__args; + + this.mutations.push({ id, data }); + + return { + updateCallRecording: { + id, + }, + }; + } + + throw new Error(`Unhandled mutation: ${JSON.stringify(mutation)}`); + } + + private filterCallRecordings(filter: any): CallRecordingNode[] { + if (filter.id?.eq !== undefined) { + return this.callRecordings.filter( + (callRecording) => callRecording.id === filter.id.eq, + ); + } + + if (filter.externalBotId?.eq !== undefined) { + return this.callRecordings.filter( + (callRecording) => + callRecording.externalBotId === filter.externalBotId.eq, + ); + } + + throw new Error( + `Unhandled call recording filter: ${JSON.stringify(filter)}`, + ); + } +} + +describe('handleRecallWebhook', () => { + it('updates a call recording from bot metadata on status change events', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'JOINING', + externalBotId: 'recall-bot-1', + }, + ]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'bot.status_change', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-1', + }, + }, + status: { + code: 'in_call_recording', + }, + }, + }, + }); + + expect(result).toEqual({ + status: 'updated', + event: 'bot.status_change', + callRecordingId: 'call-recording-1', + callRecordingStatus: 'RECORDING', + }); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + status: 'RECORDING', + externalBotId: 'recall-bot-1', + }, + }, + ]); + }); + + it('reads bot metadata nested under data when a top-level bot has none', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'JOINING', + externalBotId: 'recall-bot-1', + }, + ]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'bot.status_change', + bot: { + id: 'recall-bot-1', + }, + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-1', + }, + }, + status: { + code: 'in_call_recording', + }, + }, + }, + }); + + expect(result).toEqual({ + status: 'updated', + event: 'bot.status_change', + callRecordingId: 'call-recording-1', + callRecordingStatus: 'RECORDING', + }); + }); + + it('matches by metadata id when the recording carries no external bot id', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'SCHEDULED', + externalBotId: null, + }, + ]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'bot.status_change', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-1', + }, + }, + status: { + code: 'in_call_recording', + }, + }, + }, + }); + + expect(result).toEqual({ + status: 'updated', + event: 'bot.status_change', + callRecordingId: 'call-recording-1', + callRecordingStatus: 'RECORDING', + }); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + status: 'RECORDING', + externalBotId: 'recall-bot-1', + }, + }, + ]); + }); + + it('prefers the metadata id over a different recording carrying the bot id', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-stale', + status: 'SCHEDULED', + externalBotId: 'recall-bot-1', + }, + { + id: 'call-recording-current', + status: 'SCHEDULED', + externalBotId: null, + }, + ]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'bot.status_change', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-current', + }, + }, + status: { + code: 'in_call_recording', + }, + }, + }, + }); + + expect(result).toEqual({ + status: 'updated', + event: 'bot.status_change', + callRecordingId: 'call-recording-current', + callRecordingStatus: 'RECORDING', + }); + expect(client.mutations).toEqual([ + { + id: 'call-recording-current', + data: { + status: 'RECORDING', + externalBotId: 'recall-bot-1', + }, + }, + ]); + }); + + it('falls back to external bot id matching when metadata is absent', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + }, + ]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'recording.done', + data: { + bot_id: 'recall-bot-1', + recording: { + id: 'recall-recording-1', + }, + }, + }, + }); + + expect(result).toEqual({ + status: 'updated', + event: 'recording.done', + callRecordingId: 'call-recording-1', + callRecordingStatus: 'PROCESSING', + }); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + externalRecordingId: 'recall-recording-1', + }, + }, + ]); + }); + + it('fills startedAt from the status timestamp when the bot starts recording', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'JOINING', + externalBotId: 'recall-bot-1', + }, + ]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'bot.status_change', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-1', + }, + }, + status: { + code: 'in_call_recording', + created_at: '2026-01-01T13:02:00.000Z', + }, + }, + }, + }); + + expect(result).toEqual({ + status: 'updated', + event: 'bot.status_change', + callRecordingId: 'call-recording-1', + callRecordingStatus: 'RECORDING', + }); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + status: 'RECORDING', + externalBotId: 'recall-bot-1', + startedAt: '2026-01-01T13:02:00.000Z', + }, + }, + ]); + }); + + it('fills endedAt from the status timestamp when the recording is done', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + startedAt: '2026-01-01T13:02:00.000Z', + }, + ]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'bot.status_change', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-1', + }, + }, + status: { + code: 'done', + created_at: '2026-01-01T14:05:00.000Z', + }, + }, + }, + }); + + expect(result).toEqual({ + status: 'updated', + event: 'bot.status_change', + callRecordingId: 'call-recording-1', + callRecordingStatus: 'PROCESSING', + }); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + endedAt: '2026-01-01T14:05:00.000Z', + }, + }, + ]); + }); + + it('normalizes microsecond-precision Recall timestamps before writing them', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + startedAt: '2026-06-10T11:02:00.000Z', + }, + ]); + + await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'bot.status_change', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-1', + }, + }, + status: { + code: 'done', + created_at: '2026-06-10T12:17:28.281597+00:00', + }, + }, + }, + }); + + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + endedAt: '2026-06-10T12:17:28.281Z', + }, + }, + ]); + }); + + it('does not overwrite an already-set startedAt on a redelivered recording event', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'RECORDING', + externalBotId: 'recall-bot-1', + startedAt: '2026-01-01T13:02:00.000Z', + }, + ]); + + await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'bot.status_change', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-1', + }, + }, + status: { + code: 'in_call_recording', + created_at: '2026-01-01T13:09:00.000Z', + }, + }, + }, + }); + + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + status: 'RECORDING', + externalBotId: 'recall-bot-1', + }, + }, + ]); + }); + + it('does not overwrite an already-set endedAt on a redelivered done event', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + startedAt: '2026-01-01T13:02:00.000Z', + endedAt: '2026-01-01T14:05:00.000Z', + }, + ]); + + await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'bot.status_change', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-1', + }, + }, + status: { + code: 'done', + created_at: '2026-01-01T14:11:00.000Z', + }, + }, + }, + }); + + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + }, + }, + ]); + }); + + it('maps a fatal bot status to FAILED_UNKNOWN', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'RECORDING', + externalBotId: 'recall-bot-1', + }, + ]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'bot.status_change', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-1', + }, + }, + status: { + code: 'fatal', + }, + }, + }, + }); + + expect(result).toEqual({ + status: 'updated', + event: 'bot.status_change', + callRecordingId: 'call-recording-1', + callRecordingStatus: 'FAILED_UNKNOWN', + }); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + status: 'FAILED_UNKNOWN', + externalBotId: 'recall-bot-1', + }, + }, + ]); + }); + + it('skips a late done event once the recording is COMPLETED', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'COMPLETED', + externalBotId: 'recall-bot-1', + startedAt: '2026-01-01T13:02:00.000Z', + endedAt: '2026-01-01T14:05:00.000Z', + }, + ]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'bot.status_change', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-1', + }, + }, + status: { + code: 'done', + created_at: '2026-01-01T14:11:00.000Z', + }, + }, + }, + }); + + expect(result).toEqual({ + status: 'skipped', + event: 'bot.status_change', + reason: 'stale status event (COMPLETED -> PROCESSING)', + }); + expect(client.mutations).toEqual([]); + }); + + it('skips out-of-order events that would move the status backwards', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'COMPLETED', + externalBotId: 'recall-bot-1', + }, + ]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'bot.status_change', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-1', + }, + }, + status: { + code: 'in_call_recording', + }, + }, + }, + }); + + expect(result).toEqual({ + status: 'skipped', + event: 'bot.status_change', + reason: 'stale status event (COMPLETED -> RECORDING)', + }); + expect(client.mutations).toEqual([]); + }); + + it('skips events whose metadata points at a missing call recording', async () => { + const client = new FakeCoreApiClient([]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'bot.status_change', + data: { + bot: { + metadata: { + twentyCallRecordingId: 'call-recording-deleted', + }, + }, + status: { + code: 'in_call_recording', + }, + }, + }, + }); + + expect(result).toEqual({ + status: 'skipped', + event: 'bot.status_change', + reason: 'no matching call recording', + }); + expect(client.mutations).toEqual([]); + }); + + it('skips unsupported events', async () => { + const client = new FakeCoreApiClient([]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'participant_events.done', + data: {}, + }, + }); + + expect(result).toEqual({ + status: 'skipped', + event: 'participant_events.done', + reason: 'unsupported Recall event status participant_events.done', + }); + expect(client.mutations).toEqual([]); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/handle-recall-webhook.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/handle-recall-webhook.util.ts new file mode 100644 index 0000000000..99992f9cb8 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/handle-recall-webhook.util.ts @@ -0,0 +1,193 @@ +import { isUndefined } from '@sniptt/guards'; +import { 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 { mapRecallStatusCodeToCallRecordingStatus } from 'src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util'; +import { + parseRecallWebhookEvent, + type RecallWebhookBody, + type RecallWebhookEvent, +} from 'src/logic-functions/recall-api/parse-recall-webhook-event.util'; +import { type CallRecordingRecord } from 'src/logic-functions/types/call-recording-record.type'; + +type RecallWebhookHandlerResult = + | { + status: 'updated'; + callRecordingId: string; + event: string; + callRecordingStatus: string; + } + | { + status: 'skipped'; + event: string | null; + reason: string; + }; + +export const handleRecallWebhook = async ({ + client, + body, +}: { + client: CoreApiClient; + body: RecallWebhookBody; +}): Promise => { + const webhookEvent = parseRecallWebhookEvent(body); + + if (isUndefined(webhookEvent)) { + return { + status: 'skipped', + event: null, + reason: 'missing event type', + }; + } + + const { event, statusCode } = webhookEvent; + const callRecordingStatus = mapRecallEventToCallRecordingStatus({ + event, + statusCode, + }); + + if (isUndefined(callRecordingStatus)) { + return { + status: 'skipped', + event, + reason: `unsupported Recall event status ${statusCode ?? event}`, + }; + } + + const callRecording = await findMatchingCallRecording({ + client, + webhookEvent, + }); + + if (isUndefined(callRecording)) { + return { + status: 'skipped', + event, + reason: 'no matching call recording', + }; + } + + if ( + isCallRecordingStatusDowngrade({ + fromStatus: callRecording.status, + toStatus: callRecordingStatus, + }) + ) { + return { + status: 'skipped', + event, + reason: `stale status event (${callRecording.status} -> ${callRecordingStatus})`, + }; + } + + const updateData: CallRecordingUpdateFields = { + status: callRecordingStatus, + ...(isUndefined(webhookEvent.externalBotId) + ? {} + : { externalBotId: webhookEvent.externalBotId }), + ...buildExternalRecordingIdUpdate(webhookEvent), + ...buildRecordingTimestampsUpdate({ webhookEvent, callRecording }), + }; + + await updateCallRecording(client, { + id: callRecording.id, + data: updateData, + }); + + return { + status: 'updated', + event, + callRecordingId: callRecording.id, + callRecordingStatus, + }; +}; + +const findMatchingCallRecording = async ({ + client, + webhookEvent, +}: { + client: CoreApiClient; + webhookEvent: RecallWebhookEvent; +}): Promise => { + if (!isUndefined(webhookEvent.callRecordingIdFromMetadata)) { + const [callRecording] = await findCallRecordingsByFilter(client, { + id: { eq: webhookEvent.callRecordingIdFromMetadata }, + }); + + return callRecording; + } + + if (isUndefined(webhookEvent.externalBotId)) { + return undefined; + } + + const [callRecording] = await findCallRecordingsByFilter(client, { + externalBotId: { eq: webhookEvent.externalBotId }, + }); + + return callRecording; +}; + +const mapRecallEventToCallRecordingStatus = ({ + event, + statusCode, +}: { + event: string; + statusCode: string | undefined; +}): CallRecordingStatus | undefined => { + if (event === 'recording.done') { + return CallRecordingStatus.PROCESSING; + } + + if (event === 'recording.failed') { + return CallRecordingStatus.FAILED_UNKNOWN; + } + + return mapRecallStatusCodeToCallRecordingStatus(statusCode); +}; + +// Never overwrite an already-set actual time; redeliveries must stay idempotent. +const buildRecordingTimestampsUpdate = ({ + webhookEvent, + callRecording, +}: { + webhookEvent: RecallWebhookEvent; + callRecording: CallRecordingRecord; +}): { startedAt?: string; endedAt?: string } => { + const { event, statusCode, statusTimestamp } = webhookEvent; + + const impliesRecordingStarted = statusCode === 'in_call_recording'; + const impliesRecordingEnded = + event === 'recording.done' || + statusCode === 'call_ended' || + statusCode === 'done'; + + const startedAt = + webhookEvent.recordingStartedAt ?? + (impliesRecordingStarted ? statusTimestamp : undefined); + const endedAt = + webhookEvent.recordingEndedAt ?? + (impliesRecordingEnded ? statusTimestamp : undefined); + + return { + ...(!isUndefined(startedAt) && isUndefined(callRecording.startedAt) + ? { startedAt } + : {}), + ...(!isUndefined(endedAt) && isUndefined(callRecording.endedAt) + ? { endedAt } + : {}), + }; +}; + +const buildExternalRecordingIdUpdate = ( + webhookEvent: RecallWebhookEvent, +): { externalRecordingId?: string } => + isUndefined(webhookEvent.externalRecordingId) + ? {} + : { externalRecordingId: webhookEvent.externalRecordingId }; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/verify-recall-webhook-signature.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/verify-recall-webhook-signature.test.ts new file mode 100644 index 0000000000..b70c0fb2cd --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/verify-recall-webhook-signature.test.ts @@ -0,0 +1,122 @@ +import { createHmac } from 'crypto'; + +import { describe, expect, it } from 'vitest'; + +import { verifyRecallWebhookSignature } from 'src/logic-functions/recall-api/verify-recall-webhook-signature.util'; + +const SECRET_BYTES = Buffer.from('test-secret-abc123'); +const SECRET = `whsec_${SECRET_BYTES.toString('base64')}`; +const WEBHOOK_ID = 'msg_123'; +const WEBHOOK_TIMESTAMP = '1760000000'; +const NOW = new Date(Number(WEBHOOK_TIMESTAMP) * 1000); + +const sign = (body: string): string => + createHmac('sha256', SECRET_BYTES) + .update(`${WEBHOOK_ID}.${WEBHOOK_TIMESTAMP}.${body}`) + .digest('base64'); + +describe('verifyRecallWebhookSignature', () => { + it('accepts valid Recall webhook-* signature headers', () => { + const body = JSON.stringify({ event: 'recording.done' }); + + const result = verifyRecallWebhookSignature({ + rawBody: body, + secret: SECRET, + now: NOW, + headers: { + 'webhook-id': WEBHOOK_ID, + 'webhook-timestamp': WEBHOOK_TIMESTAMP, + 'webhook-signature': `v1,${sign(body)}`, + }, + }); + + expect(result).toEqual({ valid: true }); + }); + + it('accepts valid svix-* signature headers', () => { + const body = JSON.stringify({ event: 'recording.done' }); + + const result = verifyRecallWebhookSignature({ + rawBody: body, + secret: SECRET, + now: NOW, + headers: { + 'svix-id': WEBHOOK_ID, + 'svix-timestamp': WEBHOOK_TIMESTAMP, + 'svix-signature': `v1,${sign(body)}`, + }, + }); + + expect(result).toEqual({ valid: true }); + }); + + it('rejects deliveries whose timestamp is outside of the tolerance', () => { + const body = JSON.stringify({ event: 'recording.done' }); + + const result = verifyRecallWebhookSignature({ + rawBody: body, + secret: SECRET, + now: new Date(Number(WEBHOOK_TIMESTAMP) * 1000 + 6 * 60 * 1000), + headers: { + 'webhook-id': WEBHOOK_ID, + 'webhook-timestamp': WEBHOOK_TIMESTAMP, + 'webhook-signature': `v1,${sign(body)}`, + }, + }); + + expect(result).toEqual({ + valid: false, + error: 'Webhook timestamp is outside of the allowed tolerance', + }); + }); + + it('rejects non-numeric timestamps', () => { + const body = JSON.stringify({ event: 'recording.done' }); + + const result = verifyRecallWebhookSignature({ + rawBody: body, + secret: SECRET, + now: NOW, + headers: { + 'webhook-id': WEBHOOK_ID, + 'webhook-timestamp': 'not-a-timestamp', + 'webhook-signature': `v1,${sign(body)}`, + }, + }); + + expect(result).toEqual({ + valid: false, + error: 'Invalid webhook timestamp', + }); + }); + + it('rejects missing signature headers', () => { + const result = verifyRecallWebhookSignature({ + rawBody: '{}', + secret: SECRET, + headers: {}, + }); + + expect(result).toEqual({ + valid: false, + error: 'Missing webhook signature headers', + }); + }); + + it('rejects signatures computed from a different body', () => { + const body = JSON.stringify({ event: 'recording.done' }); + + const result = verifyRecallWebhookSignature({ + rawBody: JSON.stringify({ event: 'recording.failed' }), + secret: SECRET, + now: NOW, + headers: { + 'webhook-id': WEBHOOK_ID, + 'webhook-timestamp': WEBHOOK_TIMESTAMP, + 'webhook-signature': `v1,${sign(body)}`, + }, + }); + + expect(result.valid).toBe(false); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/normalize-recall-timestamp.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/normalize-recall-timestamp.util.ts new file mode 100644 index 0000000000..1c2dd44cde --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/normalize-recall-timestamp.util.ts @@ -0,0 +1,14 @@ +import { isUndefined } from '@sniptt/guards'; + +// Twenty rejects Recall's microsecond precision; truncate to millisecond ISO. +export const normalizeRecallTimestamp = ( + value: string | undefined, +): string | undefined => { + if (isUndefined(value)) { + return undefined; + } + + const parsed = new Date(value); + + return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString(); +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/parse-recall-webhook-event.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/parse-recall-webhook-event.util.ts new file mode 100644 index 0000000000..1f339350c8 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/parse-recall-webhook-event.util.ts @@ -0,0 +1,98 @@ +import { isUndefined } from '@sniptt/guards'; + +import { asRecord } from 'src/logic-functions/utils/as-record.util'; +import { getRecordAtPath } from 'src/logic-functions/utils/get-record-at-path.util'; +import { getString } from 'src/logic-functions/utils/get-string.util'; +import { normalizeRecallTimestamp } from 'src/logic-functions/recall-api/normalize-recall-timestamp.util'; + +export type RecallWebhookBody = { + event?: unknown; + type?: unknown; + data?: unknown; + bot?: unknown; +}; + +export type RecallWebhookEvent = { + event: string; + statusCode: string | undefined; + statusTimestamp: string | undefined; + externalBotId: string | undefined; + externalRecordingId: string | undefined; + callRecordingIdFromMetadata: string | undefined; + recordingStartedAt: string | undefined; + recordingEndedAt: string | undefined; +}; + +// The only reader of raw webhook payloads; Recall delivers several body shapes per event family. +export const parseRecallWebhookEvent = ( + body: RecallWebhookBody, +): RecallWebhookEvent | undefined => { + const event = getString(body.event) ?? getString(body.type); + + if (isUndefined(event)) { + return undefined; + } + + const data = asRecord(body.data); + const bot = asRecord(body.bot); + + return { + event, + statusCode: + getString(getRecordAtPath(data, ['status', 'code'])) ?? + getString(getRecordAtPath(data, ['data', 'code'])) ?? + getString(getRecordAtPath(bot, ['status', 'code'])) ?? + getStatusCodeFromEventName(event), + statusTimestamp: normalizeRecallTimestamp( + getString(getRecordAtPath(data, ['status', 'created_at'])) ?? + getString(getRecordAtPath(data, ['data', 'updated_at'])) ?? + getString(getRecordAtPath(bot, ['status', 'created_at'])), + ), + externalBotId: + getString(data?.bot_id) ?? + getString(getRecordAtPath(data, ['bot', 'id'])) ?? + getString(getRecordAtPath(data, ['recording', 'bot_id'])) ?? + getString(getRecordAtPath(data, ['recording', 'bot', 'id'])) ?? + getString(bot?.id), + externalRecordingId: + getString(getRecordAtPath(data, ['status', 'recording_id'])) ?? + getString(getRecordAtPath(data, ['recording', 'id'])) ?? + getString(data?.recording_id), + callRecordingIdFromMetadata: extractCallRecordingIdFromMetadata({ + data, + bot, + }), + recordingStartedAt: normalizeRecallTimestamp( + getString(getRecordAtPath(data, ['recording', 'started_at'])), + ), + recordingEndedAt: normalizeRecallTimestamp( + getString(getRecordAtPath(data, ['recording', 'completed_at'])), + ), + }; +}; + +const getStatusCodeFromEventName = (event: string): string | undefined => { + if (!event.startsWith('bot.')) { + return undefined; + } + + const statusCode = event.slice('bot.'.length); + + return statusCode === 'status_change' ? undefined : statusCode; +}; + +const extractCallRecordingIdFromMetadata = ({ + data, + bot, +}: { + data: Record | undefined; + bot: Record | undefined; +}): string | undefined => { + const metadata = + asRecord(bot?.metadata) ?? + asRecord(getRecordAtPath(data, ['bot', 'metadata'])) ?? + asRecord(getRecordAtPath(data, ['recording', 'metadata'])) ?? + asRecord(data?.metadata); + + return getString(metadata?.twentyCallRecordingId); +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/verify-recall-webhook-signature.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/verify-recall-webhook-signature.util.ts new file mode 100644 index 0000000000..f42106aae5 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/verify-recall-webhook-signature.util.ts @@ -0,0 +1,109 @@ +import { createHmac, timingSafeEqual } from 'crypto'; + +import { isUndefined } from '@sniptt/guards'; + +const RECALL_WEBHOOK_SECRET_PREFIX = 'whsec_'; +const RECALL_WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS = 5 * 60; + +export const verifyRecallWebhookSignature = ({ + rawBody, + headers, + secret, + now = new Date(), +}: { + rawBody: string; + headers: Record; + secret: string; + now?: Date; +}): { valid: true } | { valid: false; error: string } => { + if (!secret.startsWith(RECALL_WEBHOOK_SECRET_PREFIX)) { + return { + valid: false, + error: 'Webhook secret must start with whsec_', + }; + } + + const webhookId = headers['webhook-id'] ?? headers['svix-id']; + const webhookTimestamp = + headers['webhook-timestamp'] ?? headers['svix-timestamp']; + const webhookSignature = + headers['webhook-signature'] ?? headers['svix-signature']; + + if ( + isUndefined(webhookId) || + isUndefined(webhookTimestamp) || + isUndefined(webhookSignature) + ) { + return { + valid: false, + error: 'Missing webhook signature headers', + }; + } + + const webhookTimestampSeconds = Number(webhookTimestamp); + + if (!Number.isInteger(webhookTimestampSeconds)) { + return { + valid: false, + error: 'Invalid webhook timestamp', + }; + } + + const nowSeconds = Math.floor(now.getTime() / 1000); + + if ( + Math.abs(nowSeconds - webhookTimestampSeconds) > + RECALL_WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS + ) { + return { + valid: false, + error: 'Webhook timestamp is outside of the allowed tolerance', + }; + } + + const secretBytes = Buffer.from( + secret.slice(RECALL_WEBHOOK_SECRET_PREFIX.length), + 'base64', + ); + const expectedSignature = createHmac('sha256', secretBytes) + .update(`${webhookId}.${webhookTimestamp}.${rawBody}`) + .digest('base64'); + const providedSignatures = webhookSignature + .split(' ') + .map((signaturePart) => signaturePart.trim()) + .filter((signaturePart) => signaturePart !== '') + .flatMap((signaturePart) => { + if (signaturePart.startsWith('v1,') || signaturePart.startsWith('v1=')) { + return [signaturePart.slice(3).trim()]; + } + + return []; + }) + .filter((signaturePart) => signaturePart !== ''); + + if (providedSignatures.length === 0) { + return { + valid: false, + error: 'Missing v1 signature', + }; + } + + const expectedSignatureBuffer = Buffer.from(expectedSignature, 'base64'); + + for (const providedSignature of providedSignatures) { + const providedSignatureBuffer = Buffer.from(providedSignature, 'base64'); + + if (providedSignatureBuffer.length !== expectedSignatureBuffer.length) { + continue; + } + + if (timingSafeEqual(providedSignatureBuffer, expectedSignatureBuffer)) { + return { valid: true }; + } + } + + return { + valid: false, + error: 'Signature verification failed', + }; +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-webhook.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-webhook.ts new file mode 100644 index 0000000000..e1368efa91 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-webhook.ts @@ -0,0 +1,87 @@ +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 { RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/recall-webhook-logic-function-universal-identifier'; +import { RECALL_WEBHOOK_SECRET_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-webhook-secret-env-var-name'; +import { getApplicationVariableValue } from 'src/logic-functions/utils/get-application-variable-value.util'; +import { handleRecallWebhook } from 'src/logic-functions/flows/handle-recall-webhook.util'; +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; +import { type RecallWebhookBody } from 'src/logic-functions/recall-api/parse-recall-webhook-event.util'; +import { verifyRecallWebhookSignature } from 'src/logic-functions/recall-api/verify-recall-webhook-signature.util'; + +// Non-2xx makes Svix retry; a returned plain object would 200-ack permanently. +const rejectWebhook = (status: number, error: string): Response => { + console.error(`[twenty-meeting-bot] webhook rejected: ${error}`); + + return new Response({ error }, { status }); +}; + +export const recallWebhookRouteHandler = async ( + routePayload: RoutePayload, +): Promise => { + const webhookSecret = getApplicationVariableValue( + RECALL_WEBHOOK_SECRET_ENV_VAR_NAME, + ); + + if (!isNonEmptyString(webhookSecret)) { + return rejectWebhook( + 500, + 'RECALL_WEBHOOK_SECRET server variable is not set. A server admin must copy it from the Recall webhook endpoint settings and set it on the Twenty Meeting Bot application registration.', + ); + } + + const { rawBody } = routePayload; + + if (isUndefined(rawBody)) { + return rejectWebhook( + 500, + 'Raw request body was not forwarded by the server; cannot verify the webhook signature', + ); + } + + const signatureCheck = verifyRecallWebhookSignature({ + rawBody, + headers: routePayload.headers, + secret: webhookSecret, + }); + + if (!signatureCheck.valid) { + return rejectWebhook( + 401, + `Invalid webhook signature: ${signatureCheck.error}`, + ); + } + + if (isUndefined(routePayload.body) || isNull(routePayload.body)) { + return rejectWebhook(400, 'Webhook payload was empty'); + } + + return handleRecallWebhook({ + client: new CoreApiClient(), + body: routePayload.body, + }); +}; + +export default defineLogicFunction({ + universalIdentifier: RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, + name: 'recall-webhook', + description: + 'Receives Recall.ai webhook events and updates the matching CallRecording lifecycle status.', + timeoutSeconds: 30, + handler: recallWebhookRouteHandler, + httpRouteTriggerSettings: { + path: '/webhook/recall', + httpMethod: 'POST', + isAuthRequired: false, + forwardedRequestHeaders: [ + 'webhook-id', + 'webhook-timestamp', + 'webhook-signature', + 'svix-id', + 'svix-timestamp', + 'svix-signature', + ], + }, +}); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/utils/as-record.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/utils/as-record.util.ts new file mode 100644 index 0000000000..63e7cfcddd --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/utils/as-record.util.ts @@ -0,0 +1,6 @@ +import { isArray, isObject } from '@sniptt/guards'; + +export const asRecord = (value: unknown): Record | undefined => + isObject(value) && !isArray(value) + ? (value as Record) + : undefined; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/utils/get-record-at-path.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/utils/get-record-at-path.util.ts new file mode 100644 index 0000000000..1ee635a334 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/utils/get-record-at-path.util.ts @@ -0,0 +1,10 @@ +import { asRecord } from 'src/logic-functions/utils/as-record.util'; + +export const getRecordAtPath = ( + record: Record | undefined, + path: string[], +): unknown => + path.reduce( + (currentValue, pathPart) => asRecord(currentValue)?.[pathPart], + record, + );