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:
@@ -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
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+12
@@ -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,
|
||||
];
|
||||
+1
-1
@@ -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,
|
||||
});
|
||||
|
||||
+36
@@ -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);
|
||||
});
|
||||
});
|
||||
+26
-8
@@ -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,
|
||||
}),
|
||||
|
||||
+2
-11
@@ -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(/&/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) {
|
||||
|
||||
+14
@@ -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),
|
||||
);
|
||||
};
|
||||
+5
@@ -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,
|
||||
|
||||
+8
-8
@@ -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' }),
|
||||
|
||||
+5
-5
@@ -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',
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
+1
@@ -2,5 +2,6 @@ export type CallRecorderPolicyNotRequiredReason =
|
||||
| 'EVENT_CANCELED'
|
||||
| 'PREFERENCE_OFF'
|
||||
| 'MISSING_CONFERENCE_LINK'
|
||||
| 'UNSUPPORTED_MEETING_PLATFORM'
|
||||
| 'EVENT_NOT_UPCOMING'
|
||||
| 'EVENT_BEYOND_SCHEDULING_HORIZON';
|
||||
|
||||
Reference in New Issue
Block a user