From fe8ec0463abd626c017bd4bd1cd0de21c68cf2ab Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:14:13 +0530 Subject: [PATCH] Call recorder: parse conference links from calendar event text (#22555) --- .../fetch-calendar-events-by-filter.util.ts | 18 +++-- ...ract-conference-link-url-from-text.test.ts | 80 +++++++++++++++++++ .../resolve-conference-link-url.test.ts | 56 +++++++++++++ ...ract-conference-link-url-from-text.util.ts | 33 ++++++++ .../resolve-conference-link-url.util.ts | 25 ++++++ .../reconcile-call-recorder-calendar-event.ts | 20 ++++- 6 files changed, 225 insertions(+), 7 deletions(-) create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/extract-conference-link-url-from-text.test.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/resolve-conference-link-url.test.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/domain/extract-conference-link-url-from-text.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/domain/resolve-conference-link-url.util.ts diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/fetch-calendar-events-by-filter.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/fetch-calendar-events-by-filter.util.ts index 8c0ce598d2..8a1ae67ad1 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/fetch-calendar-events-by-filter.util.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/fetch-calendar-events-by-filter.util.ts @@ -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, diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/extract-conference-link-url-from-text.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/extract-conference-link-url-from-text.test.ts new file mode 100644 index 0000000000..4307e1dfad --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/extract-conference-link-url-from-text.test.ts @@ -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 &', + '
Join Zoom Meeting
', + '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(); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/resolve-conference-link-url.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/resolve-conference-link-url.test.ts new file mode 100644 index 0000000000..228912a122 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/resolve-conference-link-url.test.ts @@ -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(); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/extract-conference-link-url-from-text.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/extract-conference-link-url-from-text.util.ts new file mode 100644 index 0000000000..a4ab3b6d47 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/extract-conference-link-url-from-text.util.ts @@ -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; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/resolve-conference-link-url.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/resolve-conference-link-url.util.ts new file mode 100644 index 0000000000..84626c346c --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/resolve-conference-link-url.util.ts @@ -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) + ); +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/reconcile-call-recorder-calendar-event.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/reconcile-call-recorder-calendar-event.ts index 70bf7c6818..e095fb3dfc 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/reconcile-call-recorder-calendar-event.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/reconcile-call-recorder-calendar-event.ts @@ -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, }),