Add Recall webhook status handler to the meeting bot app (#21659)
Adds a `recall-webhook` logic function (`POST /webhook/recall`, unauthenticated) to the `twenty-meeting-bot` app. It verifies the Recall/Svix `whsec_` signature over the raw body, parses bot lifecycle events, matches the corresponding `CallRecording` (by `twentyCallRecordingId` metadata, falling back to `externalBotId`), and updates lifecycle fields — `status`, `externalBotId`, `externalRecordingId`, and `startedAt`/`endedAt` (only when unset) — guarded against stale out-of-order events that would move the status backwards. Adds the required `RECALL_WEBHOOK_SECRET` server variable. This opens the real provider test path: install the app → schedule a bot through the existing calendar-event flow → point a Recall webhook endpoint at Twenty → bot lifecycle events update the matching `CallRecording`. Unit tests cover signature verification, status mapping, the downgrade guard, metadata/bot-id matching, and timestamp fill. Deferred to later PRs: - transcript/media ingestion, file uploads, and the completion charge (so `COMPLETED` is never set here) - repair/reconcile cron jobs Also flips `DEFAULT_RECALL_REGION` to `eu-central-1` (separate commit) to match the Recall account region. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21659?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
|
||||
'9215afe6-1497-4149-a49d-e608e239bbaf';
|
||||
+152
@@ -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>,
|
||||
): RecallWebhookRoutePayload =>
|
||||
({
|
||||
headers: {},
|
||||
...overrides,
|
||||
}) as RecallWebhookRoutePayload;
|
||||
|
||||
const buildSignedHeaders = (rawBody: string): Record<string, string> => {
|
||||
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' });
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const DEFAULT_RECALL_REGION = 'ap-northeast-1';
|
||||
export const DEFAULT_RECALL_REGION = 'eu-central-1';
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const RECALL_WEBHOOK_SECRET_ENV_VAR_NAME = 'RECALL_WEBHOOK_SECRET';
|
||||
+37
@@ -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);
|
||||
});
|
||||
});
|
||||
+37
@@ -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, number> = {
|
||||
[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;
|
||||
};
|
||||
+26
@@ -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;
|
||||
}
|
||||
};
|
||||
+669
@@ -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<string, unknown> }> = [];
|
||||
|
||||
constructor(callRecordings: CallRecordingNode[]) {
|
||||
this.callRecordings = callRecordings;
|
||||
}
|
||||
|
||||
async query(query: any): Promise<any> {
|
||||
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<any> {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
+193
@@ -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<RecallWebhookHandlerResult> => {
|
||||
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<CallRecordingRecord | undefined> => {
|
||||
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 };
|
||||
+122
@@ -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);
|
||||
});
|
||||
});
|
||||
+14
@@ -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();
|
||||
};
|
||||
+98
@@ -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<string, unknown> | undefined;
|
||||
bot: Record<string, unknown> | 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);
|
||||
};
|
||||
+109
@@ -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<string, string | undefined>;
|
||||
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',
|
||||
};
|
||||
};
|
||||
+87
@@ -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<RecallWebhookBody>,
|
||||
): Promise<object> => {
|
||||
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',
|
||||
],
|
||||
},
|
||||
});
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { isArray, isObject } from '@sniptt/guards';
|
||||
|
||||
export const asRecord = (value: unknown): Record<string, unknown> | undefined =>
|
||||
isObject(value) && !isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { asRecord } from 'src/logic-functions/utils/as-record.util';
|
||||
|
||||
export const getRecordAtPath = (
|
||||
record: Record<string, unknown> | undefined,
|
||||
path: string[],
|
||||
): unknown =>
|
||||
path.reduce<unknown>(
|
||||
(currentValue, pathPart) => asRecord(currentValue)?.[pathPart],
|
||||
record,
|
||||
);
|
||||
Reference in New Issue
Block a user