Call recorder: parse conference links from calendar event text (#22555)
This commit is contained in:
+12
-6
@@ -7,7 +7,7 @@ import {
|
||||
fetchAllNodes,
|
||||
type ConnectionPage,
|
||||
} from 'src/logic-functions/data/fetch-all-nodes.util';
|
||||
import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
|
||||
import { resolveConferenceLinkUrl } from 'src/logic-functions/domain/resolve-conference-link-url.util';
|
||||
import { stripRestrictedFieldValue } from 'src/logic-functions/data/strip-restricted-field-value.util';
|
||||
|
||||
type CalendarEventNode = {
|
||||
@@ -18,6 +18,8 @@ type CalendarEventNode = {
|
||||
endsAt?: string | null;
|
||||
iCalUid?: string | null;
|
||||
conferenceLink?: { primaryLinkUrl?: string | null } | null;
|
||||
location?: string | null;
|
||||
description?: string | null;
|
||||
callRecorderPreference?: string | null;
|
||||
};
|
||||
|
||||
@@ -49,6 +51,8 @@ export const fetchCalendarEventsByFilter = async (
|
||||
conferenceLink: {
|
||||
primaryLinkUrl: true,
|
||||
},
|
||||
location: true,
|
||||
description: true,
|
||||
callRecorderPreference: true,
|
||||
},
|
||||
},
|
||||
@@ -68,11 +72,13 @@ export const fetchCalendarEventsByFilter = async (
|
||||
startsAt: calendarEvent.startsAt ?? undefined,
|
||||
endsAt: calendarEvent.endsAt ?? undefined,
|
||||
iCalUid: calendarEvent.iCalUid ?? undefined,
|
||||
conferenceLinkUrl: isNonEmptyString(
|
||||
calendarEvent.conferenceLink?.primaryLinkUrl,
|
||||
)
|
||||
? calendarEvent.conferenceLink.primaryLinkUrl
|
||||
: undefined,
|
||||
conferenceLinkUrl: resolveConferenceLinkUrl({
|
||||
conferenceLinkUrl: calendarEvent.conferenceLink?.primaryLinkUrl,
|
||||
location: stripRestrictedFieldValue(calendarEvent.location ?? undefined),
|
||||
description: stripRestrictedFieldValue(
|
||||
calendarEvent.description ?? undefined,
|
||||
),
|
||||
}),
|
||||
callRecorderPreference: isString(calendarEvent.callRecorderPreference)
|
||||
? calendarEvent.callRecorderPreference
|
||||
: undefined,
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { extractConferenceLinkUrlFromText } from 'src/logic-functions/domain/extract-conference-link-url-from-text.util';
|
||||
|
||||
describe('extractConferenceLinkUrlFromText', () => {
|
||||
it.each([
|
||||
[
|
||||
'extracts a Zoom link from a plain text invitation',
|
||||
'Felix is inviting you to a scheduled Zoom meeting.\n\nJoin Zoom Meeting\nhttps://us02web.zoom.us/j/81234567890?pwd=aBcDeF123\n\nMeeting ID: 812 3456 7890',
|
||||
'https://us02web.zoom.us/j/81234567890?pwd=aBcDeF123',
|
||||
],
|
||||
[
|
||||
'extracts a bare Zoom link (location field)',
|
||||
'https://zoom.us/j/81234567890',
|
||||
'https://zoom.us/j/81234567890',
|
||||
],
|
||||
[
|
||||
'extracts a Zoom personal room link',
|
||||
'Join me at https://company.zoom.us/my/felix today',
|
||||
'https://company.zoom.us/my/felix',
|
||||
],
|
||||
[
|
||||
'extracts a Zoom Government link',
|
||||
'Join: https://example.zoomgov.com/j/1609618851',
|
||||
'https://example.zoomgov.com/j/1609618851',
|
||||
],
|
||||
[
|
||||
'extracts a Zoom link from an HTML body and decodes &',
|
||||
'<div><a href="https://us02web.zoom.us/j/81234567890?pwd=aBcDeF123&uname=Felix">Join Zoom Meeting</a></div>',
|
||||
'https://us02web.zoom.us/j/81234567890?pwd=aBcDeF123&uname=Felix',
|
||||
],
|
||||
[
|
||||
'strips trailing sentence punctuation',
|
||||
'Join here: https://zoom.us/j/81234567890.',
|
||||
'https://zoom.us/j/81234567890',
|
||||
],
|
||||
[
|
||||
'extracts a Google Meet link',
|
||||
'Join the call on https://meet.google.com/abc-defg-hij',
|
||||
'https://meet.google.com/abc-defg-hij',
|
||||
],
|
||||
[
|
||||
'extracts a Microsoft Teams link',
|
||||
'Click here to join: https://teams.microsoft.com/l/meetup-join/19%3ameeting_ABC123%40thread.v2/0?context=%7b%22Tid%22%3a%22111%22%7d',
|
||||
'https://teams.microsoft.com/l/meetup-join/19%3ameeting_ABC123%40thread.v2/0?context=%7b%22Tid%22%3a%22111%22%7d',
|
||||
],
|
||||
[
|
||||
'extracts a Webex link',
|
||||
'Join at https://company.webex.com/company/j.php?MTID=m1234abcd',
|
||||
'https://company.webex.com/company/j.php?MTID=m1234abcd',
|
||||
],
|
||||
[
|
||||
'extracts a GoTo Meeting link',
|
||||
'https://global.gotomeeting.com/join/123456789',
|
||||
'https://global.gotomeeting.com/join/123456789',
|
||||
],
|
||||
[
|
||||
'prefers a Zoom link over a Google Meet link when both are present',
|
||||
'Video call: https://meet.google.com/abc-defg-hij\nJoin Zoom Meeting\nhttps://zoom.us/j/81234567890',
|
||||
'https://zoom.us/j/81234567890',
|
||||
],
|
||||
])('%s', (_label, text, expectedUrl) => {
|
||||
expect(extractConferenceLinkUrlFromText(text)).toBe(expectedUrl);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a non-conferencing URL', 'Agenda: https://docs.google.com/document/d/abc'],
|
||||
['a Zoom marketing page URL', 'Learn more at https://zoom.us/pricing'],
|
||||
['plain text without links', 'Conference Room A'],
|
||||
['an empty string', ''],
|
||||
['a blank string', ' '],
|
||||
])('returns undefined for %s', (_label, text) => {
|
||||
expect(extractConferenceLinkUrlFromText(text)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for null and undefined', () => {
|
||||
expect(extractConferenceLinkUrlFromText(null)).toBeUndefined();
|
||||
expect(extractConferenceLinkUrlFromText(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveConferenceLinkUrl } from 'src/logic-functions/domain/resolve-conference-link-url.util';
|
||||
|
||||
describe('resolveConferenceLinkUrl', () => {
|
||||
it('returns the structured conference link when present', () => {
|
||||
expect(
|
||||
resolveConferenceLinkUrl({
|
||||
conferenceLinkUrl: 'https://meet.google.com/abc-defg-hij',
|
||||
location: 'https://zoom.us/j/81234567890',
|
||||
description: 'Join Zoom Meeting https://zoom.us/j/99999999999',
|
||||
}),
|
||||
).toBe('https://meet.google.com/abc-defg-hij');
|
||||
});
|
||||
|
||||
it('parses the location when the structured link is empty', () => {
|
||||
expect(
|
||||
resolveConferenceLinkUrl({
|
||||
conferenceLinkUrl: undefined,
|
||||
location: 'https://zoom.us/j/81234567890',
|
||||
description: 'Weekly sync',
|
||||
}),
|
||||
).toBe('https://zoom.us/j/81234567890');
|
||||
});
|
||||
|
||||
it('parses the description when the structured link and location have none', () => {
|
||||
expect(
|
||||
resolveConferenceLinkUrl({
|
||||
conferenceLinkUrl: '',
|
||||
location: 'Conference Room A',
|
||||
description:
|
||||
'Felix is inviting you to a scheduled Zoom meeting.\nJoin Zoom Meeting\nhttps://us02web.zoom.us/j/81234567890?pwd=aBcDeF123',
|
||||
}),
|
||||
).toBe('https://us02web.zoom.us/j/81234567890?pwd=aBcDeF123');
|
||||
});
|
||||
|
||||
it('prefers the location link over the description link', () => {
|
||||
expect(
|
||||
resolveConferenceLinkUrl({
|
||||
conferenceLinkUrl: null,
|
||||
location: 'https://zoom.us/j/11111111111',
|
||||
description: 'Join Zoom Meeting https://zoom.us/j/22222222222',
|
||||
}),
|
||||
).toBe('https://zoom.us/j/11111111111');
|
||||
});
|
||||
|
||||
it('returns undefined when no conference link exists anywhere', () => {
|
||||
expect(
|
||||
resolveConferenceLinkUrl({
|
||||
conferenceLinkUrl: undefined,
|
||||
location: 'Conference Room A',
|
||||
description: 'Quarterly review agenda',
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
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(/[.,;:!?)\]]+$/, '');
|
||||
|
||||
export const extractConferenceLinkUrlFromText = (
|
||||
text: string | null | undefined,
|
||||
): string | undefined => {
|
||||
if (!isNonEmptyString(text)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const pattern of CONFERENCE_LINK_URL_PATTERNS) {
|
||||
const match = text.match(pattern);
|
||||
|
||||
if (match !== null) {
|
||||
return cleanExtractedConferenceLinkUrl(match[0]);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { extractConferenceLinkUrlFromText } from 'src/logic-functions/domain/extract-conference-link-url-from-text.util';
|
||||
import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
|
||||
|
||||
type ResolveConferenceLinkUrlInput = {
|
||||
conferenceLinkUrl: string | null | undefined;
|
||||
location: string | null | undefined;
|
||||
description: string | null | undefined;
|
||||
};
|
||||
|
||||
// The server only fills conferenceLink from structured provider data; Zoom and
|
||||
// other third-party links often exist only as text in location/description.
|
||||
export const resolveConferenceLinkUrl = ({
|
||||
conferenceLinkUrl,
|
||||
location,
|
||||
description,
|
||||
}: ResolveConferenceLinkUrlInput): string | undefined => {
|
||||
if (isNonEmptyString(conferenceLinkUrl)) {
|
||||
return conferenceLinkUrl;
|
||||
}
|
||||
|
||||
return (
|
||||
extractConferenceLinkUrlFromText(location) ??
|
||||
extractConferenceLinkUrlFromText(description)
|
||||
);
|
||||
};
|
||||
+19
-1
@@ -11,6 +11,8 @@ import { type RemovedCallRecorderOccurrence } from 'src/logic-functions/types/re
|
||||
import { computeRealMeetingKey } from 'src/logic-functions/domain/compute-real-meeting-key.util';
|
||||
import { getUniqueSortedIds } from 'src/logic-functions/utils/get-unique-sorted-ids.util';
|
||||
import { reconcileCallRecorderForCalendarEventIds } from 'src/logic-functions/flows/reconcile-call-recorder.util';
|
||||
import { resolveConferenceLinkUrl } from 'src/logic-functions/domain/resolve-conference-link-url.util';
|
||||
import { stripRestrictedFieldValue } from 'src/logic-functions/data/strip-restricted-field-value.util';
|
||||
|
||||
const CALENDAR_EVENT_OBJECT_NAME = 'calendarEvent';
|
||||
|
||||
@@ -18,14 +20,20 @@ const CALL_RECORDER_RELEVANT_CALENDAR_EVENT_FIELDS = [
|
||||
'title',
|
||||
'callRecorderPreference',
|
||||
'conferenceLink',
|
||||
'location',
|
||||
'description',
|
||||
'startsAt',
|
||||
'endsAt',
|
||||
'isCanceled',
|
||||
'iCalUid',
|
||||
];
|
||||
|
||||
// location and description are key fields because the conference link is
|
||||
// parsed out of them when the structured conferenceLink is empty.
|
||||
const CALL_RECORDER_KEY_CALENDAR_EVENT_FIELDS = [
|
||||
'conferenceLink',
|
||||
'location',
|
||||
'description',
|
||||
'startsAt',
|
||||
'iCalUid',
|
||||
];
|
||||
@@ -33,6 +41,8 @@ const CALL_RECORDER_KEY_CALENDAR_EVENT_FIELDS = [
|
||||
type CalendarEventForDatabaseEvent = {
|
||||
id: string;
|
||||
conferenceLink?: { primaryLinkUrl?: string | null } | null;
|
||||
location?: string | null;
|
||||
description?: string | null;
|
||||
iCalUid?: string | null;
|
||||
startsAt?: string | null;
|
||||
};
|
||||
@@ -156,7 +166,15 @@ const buildRemovedOccurrence = (
|
||||
calendarEventId: calendarEvent.id,
|
||||
realMeetingKey: computeRealMeetingKey({
|
||||
calendarEventId: calendarEvent.id,
|
||||
conferenceLinkUrl: calendarEvent.conferenceLink?.primaryLinkUrl,
|
||||
conferenceLinkUrl: resolveConferenceLinkUrl({
|
||||
conferenceLinkUrl: calendarEvent.conferenceLink?.primaryLinkUrl,
|
||||
location: stripRestrictedFieldValue(
|
||||
calendarEvent.location ?? undefined,
|
||||
),
|
||||
description: stripRestrictedFieldValue(
|
||||
calendarEvent.description ?? undefined,
|
||||
),
|
||||
}),
|
||||
iCalUid: calendarEvent.iCalUid ?? undefined,
|
||||
startsAt: calendarEvent.startsAt ?? undefined,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user