Ignore call-recorder bots for unsupported meeting platforms (#23050)

## What

The call-recorder scheduled a Recall bot for any calendar event that had
a conference link, even when the link pointed to a platform Recall
cannot join (e.g. ro.am, Daily, Whereby, or a plain dial-in). Those
requests could never produce a recording.

This adds a supported-platform check to the recording policy so
unsupported links are ignored, with a dedicated reason, and documents
the supported platforms in the app README.

## Changes

- Add `SUPPORTED_MEETING_PLATFORM_URL_PATTERNS` constant (Zoom, Google
Meet, Microsoft Teams, Webex, GoTo Meeting), extracted from the existing
link-extraction patterns so extraction and validation share one source
of truth.
- Add `isSupportedMeetingPlatformUrl` util.
- `resolveCallRecorderPolicyResult` now returns
`UNSUPPORTED_MEETING_PLATFORM` (bot not required) when the resolved
conference link is not a supported platform.
- Document supported platforms and the ignore behavior in the
call-recorder README.

## Tests

- New unit tests for `isSupportedMeetingPlatformUrl`.
- New policy test for the unsupported-platform case; updated existing
policy tests to use real supported URLs.
- All call-recorder unit tests pass; typecheck and lint clean.

Closes twentyhq/core-team-issues#2705


---
_Generated by [Claude
Code](https://claude.ai/code/session_01MWPkbdUg4QMdj4FM5mtNww)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23050?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:
martmull
2026-07-20 13:49:21 +02:00
committed by GitHub
parent 70e4d8d36e
commit fa720358d9
12 changed files with 125 additions and 34 deletions
@@ -21,6 +21,20 @@ cost scales with how much was said in the meeting, typically **$0.02$0.06 per
meeting** on default models. Set the `CALL_RECORDER_SUMMARY_ENABLED` app
variable to `false` to turn summaries off.
## 🎥 Supported meeting platforms
The recording bot can only join meetings on these platforms:
- ✅ Google Meet
- ✅ Zoom
- ✅ Microsoft Teams
- ✅ Webex
- ✅ GoTo Meeting
Events whose conference link points to any other platform (e.g. ro.am, Daily,
Whereby) or that only have a dial-in number are **ignored** — no bot is
scheduled, since it can't join the call.
## 📌 Heads up
- **Needs a synced calendar + video link** — ad-hoc calls that were never on
@@ -411,7 +411,7 @@ describe('call recorder app lifecycle (integration)', () => {
endsAt: inTwoHours(),
iCalUid: `call-recorder-test-${calendarEventId}`,
conferenceLink: {
primaryLinkUrl: `https://meet.example.com/${calendarEventId}`,
primaryLinkUrl: `https://meet.google.com/${calendarEventId}`,
},
callRecorderPreference: 'ON',
...overrides,
@@ -0,0 +1,12 @@
// Meeting platforms a Recall bot can join. Anything else (Around, Whereby,
// ro.am, Daily, plain dial-in, etc.) cannot be recorded and is ignored.
// See https://docs.recall.ai/docs/meeting-platforms
// Ordered by provider precedence: the first matching provider wins (e.g. a
// pasted Zoom invitation beats an auto-added Meet link).
export const SUPPORTED_MEETING_PLATFORM_URL_PATTERNS: RegExp[] = [
/https:\/\/(?:[\w-]+\.)*(?:zoom\.us|zoomgov\.com)\/(?:j|my|s|w|wc\/join)\/[^\s"'<>\\]+/i,
/https:\/\/meet\.google\.com\/[^\s"'<>\\]+/i,
/https:\/\/teams\.(?:microsoft|live)\.com\/(?:l\/meetup-join|meet)\/[^\s"'<>\\]+/i,
/https:\/\/(?:[\w-]+\.)*webex\.com\/(?:meet\/|join\/|[\w-]+\/j\.php\?)[^\s"'<>\\]+/i,
/https:\/\/(?:[\w-]+\.)*(?:gotomeeting\.com\/join|gotomeet\.me)\/[^\s"'<>\\]+/i,
];
@@ -13,7 +13,7 @@ const buildCalendarEventInput = (
startsAt: '2026-01-01T13:00:00.000Z',
endsAt: '2026-01-01T14:00:00.000Z',
iCalUid: 'ical-uid-1',
conferenceLinkUrl: 'https://meet.example.com/customer-sync',
conferenceLinkUrl: 'https://meet.google.com/customer-sync',
callRecorderPreference: undefined,
...overrides,
});
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { isSupportedMeetingPlatformUrl } from 'src/logic-functions/domain/is-supported-meeting-platform-url.util';
describe('isSupportedMeetingPlatformUrl', () => {
it.each([
['a Zoom link', 'https://zoom.us/j/81234567890'],
['a Zoom subdomain link', 'https://us02web.zoom.us/j/81234567890?pwd=aBc'],
['a Google Meet link', 'https://meet.google.com/abc-defg-hij'],
[
'a Microsoft Teams link',
'https://teams.microsoft.com/l/meetup-join/19%3ameeting_ABC%40thread.v2/0',
],
['a Webex link', 'https://company.webex.com/company/j.php?MTID=m1234abcd'],
['a GoTo Meeting link', 'https://global.gotomeeting.com/join/123456789'],
])('returns true for %s', (_label, url) => {
expect(isSupportedMeetingPlatformUrl(url)).toBe(true);
});
it.each([
['a ro.am link', 'https://ro.am/r/#/d/123'],
['a Daily link', 'https://example.daily.co/room'],
['a Whereby link', 'https://whereby.com/team-room'],
['a Zoom marketing page', 'https://zoom.us/pricing'],
['a non-conferencing URL', 'https://docs.google.com/document/d/abc'],
['an empty string', ''],
['a blank string', ' '],
])('returns false for %s', (_label, url) => {
expect(isSupportedMeetingPlatformUrl(url)).toBe(false);
});
it('returns false for null and undefined', () => {
expect(isSupportedMeetingPlatformUrl(null)).toBe(false);
expect(isSupportedMeetingPlatformUrl(undefined)).toBe(false);
});
});
@@ -21,7 +21,7 @@ describe('resolveCallRecorderPolicyResult', () => {
isCanceled: false,
startsAt: FUTURE_STARTS_AT,
endsAt: FUTURE_ENDS_AT,
conferenceLinkUrl: 'https://meet.example.com/team-sync',
conferenceLinkUrl: 'https://meet.google.com/abc-defg-hij',
},
now: NOW,
}),
@@ -49,6 +49,24 @@ describe('resolveCallRecorderPolicyResult', () => {
});
});
it('does not request a bot when the conference link is an unsupported platform', () => {
expect(
resolveCallRecorderPolicyResult({
input: {
callRecorderPreference: CallRecorderPreference.ON,
isCanceled: false,
startsAt: FUTURE_STARTS_AT,
endsAt: FUTURE_ENDS_AT,
conferenceLinkUrl: 'https://ro.am/r/#/d/123',
},
now: NOW,
}),
).toEqual({
shouldRequestBot: false,
reason: 'UNSUPPORTED_MEETING_PLATFORM',
});
});
it('requires a bot without an event preference override', () => {
expect(
resolveCallRecorderPolicyResult({
@@ -57,7 +75,7 @@ describe('resolveCallRecorderPolicyResult', () => {
isCanceled: false,
startsAt: FUTURE_STARTS_AT,
endsAt: FUTURE_ENDS_AT,
conferenceLinkUrl: 'https://meet.example.com/team-sync',
conferenceLinkUrl: 'https://meet.google.com/abc-defg-hij',
},
now: NOW,
}),
@@ -75,7 +93,7 @@ describe('resolveCallRecorderPolicyResult', () => {
isCanceled: false,
startsAt: FUTURE_STARTS_AT,
endsAt: FUTURE_ENDS_AT,
conferenceLinkUrl: 'https://meet.example.com/team-sync',
conferenceLinkUrl: 'https://meet.google.com/abc-defg-hij',
},
now: NOW,
}),
@@ -93,7 +111,7 @@ describe('resolveCallRecorderPolicyResult', () => {
isCanceled: false,
startsAt: PAST_STARTS_AT,
endsAt: PAST_ENDS_AT,
conferenceLinkUrl: 'https://meet.example.com/team-sync',
conferenceLinkUrl: 'https://meet.google.com/abc-defg-hij',
},
now: NOW,
}),
@@ -111,7 +129,7 @@ describe('resolveCallRecorderPolicyResult', () => {
isCanceled: true,
startsAt: FUTURE_STARTS_AT,
endsAt: FUTURE_ENDS_AT,
conferenceLinkUrl: 'https://meet.example.com/team-sync',
conferenceLinkUrl: 'https://meet.google.com/abc-defg-hij',
},
now: NOW,
}),
@@ -129,7 +147,7 @@ describe('resolveCallRecorderPolicyResult', () => {
isCanceled: false,
startsAt: BEYOND_HORIZON_STARTS_AT,
endsAt: BEYOND_HORIZON_ENDS_AT,
conferenceLinkUrl: 'https://meet.example.com/team-sync',
conferenceLinkUrl: 'https://meet.google.com/abc-defg-hij',
},
now: NOW,
}),
@@ -147,7 +165,7 @@ describe('resolveCallRecorderPolicyResult', () => {
isCanceled: false,
startsAt: FUTURE_STARTS_AT,
endsAt: BEYOND_HORIZON_ENDS_AT,
conferenceLinkUrl: 'https://meet.example.com/team-sync',
conferenceLinkUrl: 'https://meet.google.com/abc-defg-hij',
},
now: NOW,
}),
@@ -165,7 +183,7 @@ describe('resolveCallRecorderPolicyResult', () => {
isCanceled: false,
startsAt: '',
endsAt: FUTURE_ENDS_AT,
conferenceLinkUrl: 'https://meet.example.com/team-sync',
conferenceLinkUrl: 'https://meet.google.com/abc-defg-hij',
},
now: NOW,
}),
@@ -1,15 +1,6 @@
import { SUPPORTED_MEETING_PLATFORM_URL_PATTERNS } from 'src/logic-functions/constants/supported-meeting-platform-url-patterns';
import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
// Ordered by provider precedence: the first matching provider wins (e.g. a
// pasted Zoom invitation beats an auto-added Meet link).
const CONFERENCE_LINK_URL_PATTERNS: RegExp[] = [
/https:\/\/(?:[\w-]+\.)*(?:zoom\.us|zoomgov\.com)\/(?:j|my|s|w|wc\/join)\/[^\s"'<>\\]+/i,
/https:\/\/meet\.google\.com\/[^\s"'<>\\]+/i,
/https:\/\/teams\.(?:microsoft|live)\.com\/(?:l\/meetup-join|meet)\/[^\s"'<>\\]+/i,
/https:\/\/(?:[\w-]+\.)*webex\.com\/(?:meet\/|join\/|[\w-]+\/j\.php\?)[^\s"'<>\\]+/i,
/https:\/\/(?:[\w-]+\.)*(?:gotomeeting\.com\/join|gotomeet\.me)\/[^\s"'<>\\]+/i,
];
// Outlook HTML bodies encode ampersands; plain-text links can end mid-sentence.
const cleanExtractedConferenceLinkUrl = (url: string): string =>
url.replace(/&amp;/gi, '&').replace(/[.,;:!?)\]]+$/, '');
@@ -21,7 +12,7 @@ export const extractConferenceLinkUrlFromText = (
return undefined;
}
for (const pattern of CONFERENCE_LINK_URL_PATTERNS) {
for (const pattern of SUPPORTED_MEETING_PLATFORM_URL_PATTERNS) {
const match = text.match(pattern);
if (match !== null) {
@@ -0,0 +1,14 @@
import { SUPPORTED_MEETING_PLATFORM_URL_PATTERNS } from 'src/logic-functions/constants/supported-meeting-platform-url-patterns';
import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
export const isSupportedMeetingPlatformUrl = (
url: string | null | undefined,
): boolean => {
if (!isNonEmptyString(url)) {
return false;
}
return SUPPORTED_MEETING_PLATFORM_URL_PATTERNS.some((pattern) =>
pattern.test(url),
);
};
@@ -2,6 +2,7 @@ import { isUndefined } from '@sniptt/guards';
import { CallRecorderPreference } from 'src/constants/call-recorder-preference';
import { computeUpcomingCalendarEventHorizonEnd } from 'src/logic-functions/domain/compute-upcoming-calendar-event-horizon-end.util';
import { isSupportedMeetingPlatformUrl } from 'src/logic-functions/domain/is-supported-meeting-platform-url.util';
import { type CallRecorderPolicyInput } from 'src/logic-functions/types/call-recorder-policy-input.type';
import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
import { type CallRecorderPolicyNotRequiredReason } from 'src/logic-functions/types/call-recorder-policy-not-required-reason.type';
@@ -29,6 +30,10 @@ export const resolveCallRecorderPolicyResult = ({
return botNotRequired('MISSING_CONFERENCE_LINK');
}
if (!isSupportedMeetingPlatformUrl(input.conferenceLinkUrl)) {
return botNotRequired('UNSUPPORTED_MEETING_PLATFORM');
}
if (
!isCalendarEventInFuture({
startsAt: input.startsAt,
@@ -43,7 +43,7 @@ const buildCustomerSyncCallRecordingId = (
startsAt: string = FUTURE_STARTS_AT,
): string =>
computeCallRecordingIdForMeeting(
`link:meet.example.com/customer-sync:${startsAt}`,
`link:meet.google.com/customer-sync:${startsAt}`,
);
type CalendarEventNode = {
@@ -207,7 +207,7 @@ const buildCalendarEvent = (
endsAt: FUTURE_ENDS_AT,
iCalUid: 'calendar-event-uid',
conferenceLink: {
primaryLinkUrl: 'https://meet.example.com/customer-sync',
primaryLinkUrl: 'https://meet.google.com/customer-sync',
},
callRecorderPreference: 'ON',
...overrides,
@@ -293,7 +293,7 @@ describe('reconcileCallRecorderForCalendarEventIds', () => {
);
expect(JSON.parse(createBotInit.body ?? '')).toEqual(
expect.objectContaining({
meeting_url: 'https://meet.example.com/customer-sync',
meeting_url: 'https://meet.google.com/customer-sync',
join_at: FUTURE_RECALL_BOT_JOIN_AT,
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
@@ -471,7 +471,7 @@ describe('reconcileCallRecorderForCalendarEventIds', () => {
expect(updateBotUrl).toBe(`${RECALL_API_BASE_URL}/bot/recall-bot-1/`);
expect(JSON.parse(updateBotInit.body ?? '')).toEqual(
expect.objectContaining({
meeting_url: 'https://meet.example.com/customer-sync',
meeting_url: 'https://meet.google.com/customer-sync',
join_at: FUTURE_RECALL_BOT_JOIN_AT,
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
@@ -742,7 +742,7 @@ describe('reconcileCallRecorderForCalendarEventIds', () => {
removedOccurrences: [
{
calendarEventId: 'calendar-event-1',
realMeetingKey: `link:meet.example.com/customer-sync:${FUTURE_STARTS_AT}`,
realMeetingKey: `link:meet.google.com/customer-sync:${FUTURE_STARTS_AT}`,
startsAt: FUTURE_STARTS_AT,
},
],
@@ -798,7 +798,7 @@ describe('reconcileCallRecorderForCalendarEventIds', () => {
removedOccurrences: [
{
calendarEventId: 'calendar-event-1',
realMeetingKey: `link:meet.example.com/customer-sync:${FUTURE_STARTS_AT}`,
realMeetingKey: `link:meet.google.com/customer-sync:${FUTURE_STARTS_AT}`,
startsAt: FUTURE_STARTS_AT,
},
],
@@ -860,7 +860,7 @@ describe('reconcileCallRecorderForCalendarEventIds', () => {
id: 'calendar-event-2',
iCalUid: 'other-meeting-uid',
conferenceLink: {
primaryLinkUrl: 'https://meet.example.com/other-sync',
primaryLinkUrl: 'https://meet.google.com/other-sync',
},
}),
],
@@ -887,7 +887,7 @@ describe('reconcileCallRecorderForCalendarEventIds', () => {
expect(result).toEqual([
expect.objectContaining({
action: 'FAILED',
realMeetingKey: `link:meet.example.com/customer-sync:${FUTURE_STARTS_AT}`,
realMeetingKey: `link:meet.google.com/customer-sync:${FUTURE_STARTS_AT}`,
errorMessage: 'recall exploded',
}),
expect.objectContaining({ action: 'CREATED' }),
@@ -16,7 +16,7 @@ const CLIENT: CoreApiClient = Object.assign(
},
);
const MEETING_URL = 'https://meet.example.com/abc';
const MEETING_URL = 'https://meet.google.com/abc';
const MEETING_STARTS_AT = new Date(Date.now() + 60 * 60 * 1000).toISOString();
const MEETING_ENDS_AT = new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString();
@@ -291,24 +291,24 @@ describe('reconcileUpcomingCalendarEventBatches', () => {
calendarEventNodesById: {
'calendar-event-1': buildCalendarEventNode('calendar-event-1', {
conferenceLink: {
primaryLinkUrl: 'https://meet.example.com/created',
primaryLinkUrl: 'https://meet.google.com/created',
},
}),
'calendar-event-2': buildCalendarEventNode('calendar-event-2', {
isCanceled: true,
conferenceLink: {
primaryLinkUrl: 'https://meet.example.com/canceled',
primaryLinkUrl: 'https://meet.google.com/canceled',
},
}),
'calendar-event-3': buildCalendarEventNode('calendar-event-3', {
isCanceled: true,
conferenceLink: {
primaryLinkUrl: 'https://meet.example.com/skipped',
primaryLinkUrl: 'https://meet.google.com/skipped',
},
}),
'calendar-event-4': buildCalendarEventNode('calendar-event-4', {
conferenceLink: {
primaryLinkUrl: 'https://meet.example.com/failed',
primaryLinkUrl: 'https://meet.google.com/failed',
},
}),
},
@@ -2,5 +2,6 @@ export type CallRecorderPolicyNotRequiredReason =
| 'EVENT_CANCELED'
| 'PREFERENCE_OFF'
| 'MISSING_CONFERENCE_LINK'
| 'UNSUPPORTED_MEETING_PLATFORM'
| 'EVENT_NOT_UPCOMING'
| 'EVENT_BEYOND_SCHEDULING_HORIZON';