From 242f86891ab4b1a8f74c25a2b86f874aa4577fe9 Mon Sep 17 00:00:00 2001 From: Vijay Date: Mon, 11 Aug 2025 08:11:45 +0530 Subject: [PATCH] fix: MSTeams not created as online meetings (#21377) * fix MSTeams formatting and duplicate events * update without changing scopes, create onlineMeetings through CalendarService instead of VideoApiAdapter * get url after online meeting is created required for downstream * fallback to callvideo if only MSteams and update evt.videoCallData with url * for backward compatibility set explicitly 'Microsoft Teams Meeting' for old meetings if rescheduled * add dependency to msTeams App * nit * use interpolation * update to create MSTeams event if only MSTeams installed and not Outlook Calendar * make function MSTeams specific --------- Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> --- packages/app-store/components.tsx | 7 +- packages/app-store/locations.ts | 2 + .../office365calendar/lib/CalendarService.ts | 66 +++++++++++++++++-- .../app-store/office365video/_metadata.ts | 1 + packages/app-store/office365video/config.json | 1 + packages/lib/CalEventParser.ts | 58 ++++++++++++++++ packages/lib/EventManager.ts | 41 +++++++++++- 7 files changed, 166 insertions(+), 10 deletions(-) diff --git a/packages/app-store/components.tsx b/packages/app-store/components.tsx index ec99ba3028..53397a6d26 100644 --- a/packages/app-store/components.tsx +++ b/packages/app-store/components.tsx @@ -97,6 +97,7 @@ export const AppDependencyComponent = ({ {t("this_app_requires_connected_account", { appName, dependencyName: dependency.name, + interpolation: { escapeValue: false }, })} @@ -112,7 +113,11 @@ export const AppDependencyComponent = ({
- {t("this_app_requires_connected_account", { appName, dependencyName: dependency.name })} + {t("this_app_requires_connected_account", { + appName, + dependencyName: dependency.name, + interpolation: { escapeValue: false }, + })}
diff --git a/packages/app-store/locations.ts b/packages/app-store/locations.ts index bb5deeb94e..c5a911554e 100644 --- a/packages/app-store/locations.ts +++ b/packages/app-store/locations.ts @@ -61,6 +61,8 @@ export const DailyLocationType = "integrations:daily"; export const MeetLocationType = "integrations:google:meet"; +export const MSTeamsLocationType = "integrations:office365_video"; + /** * This isn't an actual location app type. It is a special value that informs to use the Organizer's default conferencing app during booking */ diff --git a/packages/app-store/office365calendar/lib/CalendarService.ts b/packages/app-store/office365calendar/lib/CalendarService.ts index 6579b0949d..04626103f4 100644 --- a/packages/app-store/office365calendar/lib/CalendarService.ts +++ b/packages/app-store/office365calendar/lib/CalendarService.ts @@ -1,8 +1,9 @@ import type { Calendar as OfficeCalendar, User, Event } from "@microsoft/microsoft-graph-types-beta"; import type { DefaultBodyType } from "msw"; +import { MSTeamsLocationType } from "@calcom/app-store/locations"; import dayjs from "@calcom/dayjs"; -import { getLocation } from "@calcom/lib/CalEventParser"; +import { getLocation, getRichDescriptionHTML } from "@calcom/lib/CalEventParser"; import { CalendarAppDelegationCredentialInvalidGrantError, CalendarAppDelegationCredentialConfigurationError, @@ -254,7 +255,13 @@ export default class Office365CalendarService implements Calendar { body: JSON.stringify(this.translateEvent(event)), }); - const responseJson = await handleErrorsJson(response); + const responseJson = await handleErrorsJson< + NewCalendarEventType & { iCalUId: string; onlineMeeting?: { joinUrl?: string } } + >(response); + + if (responseJson?.onlineMeeting?.joinUrl) { + responseJson.url = responseJson?.onlineMeeting?.joinUrl; + } return { ...responseJson, iCalUID: responseJson.iCalUId }; } catch (error) { @@ -266,12 +273,28 @@ export default class Office365CalendarService implements Calendar { async updateEvent(uid: string, event: CalendarServiceEvent): Promise { try { + let rescheduledEvent: Event | undefined; + if (event.location === MSTeamsLocationType) { + // Extract the existing body content to preserve the meeting blob, otherwise it breaks and converts it into non-onlineMeeting + const response = await this.fetcher(`${await this.getUserEndpoint()}/calendar/events/${uid}`, { + method: "GET", + }); + + rescheduledEvent = await handleErrorsJson(response); + } + const response = await this.fetcher(`${await this.getUserEndpoint()}/calendar/events/${uid}`, { method: "PATCH", - body: JSON.stringify(this.translateEvent(event)), + body: JSON.stringify(this.translateEvent(event, rescheduledEvent)), }); - const responseJson = await handleErrorsJson(response); + const responseJson = await handleErrorsJson< + NewCalendarEventType & { iCalUId: string; onlineMeeting?: { joinUrl?: string } } + >(response); + + if (responseJson?.onlineMeeting?.joinUrl) { + responseJson.url = responseJson?.onlineMeeting?.joinUrl; + } return { ...responseJson, iCalUID: responseJson.iCalUId }; } catch (error) { @@ -401,12 +424,30 @@ export default class Office365CalendarService implements Calendar { }); } - private translateEvent = (event: CalendarServiceEvent) => { + private translateEvent = (event: CalendarServiceEvent, rescheduledEvent?: Event) => { + const isOnlineMeeting = event.location === MSTeamsLocationType; + const isRescheduledOnlineMeeting = rescheduledEvent ? rescheduledEvent.isOnlineMeeting : false; + const existingBody = + rescheduledEvent?.body?.contentType === "html" ? rescheduledEvent.body.content : undefined; + + let content = ""; + if (isOnlineMeeting) { + if (isRescheduledOnlineMeeting && existingBody) { + content = ` + ${getRichDescriptionHTML(event)}
+ ${existingBody}`.trim(); + } else { + content = getRichDescriptionHTML(event); + } + } else { + content = event.calendarDescription; + } + const office365Event: Event = { subject: event.title, body: { - contentType: "text", - content: event.calendarDescription, + contentType: isOnlineMeeting ? "html" : "text", + content, }, start: { dateTime: dayjs(event.startTime).tz(event.organizer.timeZone).format("YYYY-MM-DDTHH:mm:ss"), @@ -456,6 +497,17 @@ export default class Office365CalendarService implements Calendar { if (event.hideCalendarEventDetails) { office365Event.sensitivity = "private"; } + if (isOnlineMeeting) { + office365Event.isOnlineMeeting = true; + office365Event.allowNewTimeProposals = true; + office365Event.onlineMeetingProvider = "teamsForBusiness"; + // MSTeams sets location as 'Microsoft Teams Meeting' by default, if location is undefined. + // For backward compatibility, setting explicitly. + office365Event.location = + rescheduledEvent && !isRescheduledOnlineMeeting + ? { displayName: "Microsoft Teams Meeting" } + : undefined; + } return office365Event; }; diff --git a/packages/app-store/office365video/_metadata.ts b/packages/app-store/office365video/_metadata.ts index 644048c122..7eeda9685c 100644 --- a/packages/app-store/office365video/_metadata.ts +++ b/packages/app-store/office365video/_metadata.ts @@ -21,6 +21,7 @@ export const metadata = { publisher: "Cal.com", slug: "msteams", dirName: "office365video", + dependencies: ["office365-calendar"], url: "https://www.microsoft.com/en-ca/microsoft-teams/group-chat-software", email: "help@cal.com", isOAuth: true, diff --git a/packages/app-store/office365video/config.json b/packages/app-store/office365video/config.json index 9b663ad63d..64da746b4c 100644 --- a/packages/app-store/office365video/config.json +++ b/packages/app-store/office365video/config.json @@ -22,6 +22,7 @@ } }, "dirName": "office365video", + "dependencies": ["office365-calendar"], "concurrentMeetings": true, "isOAuth": true } diff --git a/packages/lib/CalEventParser.ts b/packages/lib/CalEventParser.ts index c51d2d7a5f..08d0a6f2e4 100644 --- a/packages/lib/CalEventParser.ts +++ b/packages/lib/CalEventParser.ts @@ -314,6 +314,64 @@ type RichDescriptionCalEvent = Parameters[0] & Parameters[0] & Pick; +export const getRichDescriptionHTML = ( + calEvent: RichDescriptionCalEvent, + t_?: TFunction, + includeAppStatus = false +) => { + const t = t_ ?? calEvent.organizer.language.translate; + + // Helper function to convert plain text with newlines to HTML paragraphs + const textToHtml = (text: string) => { + if (!text) return ""; + const lines = text.split("\n").filter(Boolean); + return lines + .map((line, index) => { + if (index === 0) { + return `

${line}

`; + } + return `

${line}

`; + }) + .join(""); + }; + + // Convert the manage link to a clickable hyperlink + const manageLinkText = getManageLink(calEvent, t); + const manageLinkHtml = manageLinkText + ? (() => { + const words = manageLinkText.split(" "); + const lastWord = words.pop(); + if (lastWord && lastWord.includes("http")) { + const textWithoutLink = words.join(" ").trim(); + return `

${textWithoutLink} Click here

`; + } + return `

${manageLinkText}

`; + })() + : ""; + + // Build the HTML content for each section + const parts = [ + textToHtml(getCancellationReason(calEvent, t)), + textToHtml(getWhat(calEvent, t)), + textToHtml(getWhen(calEvent, t)), + textToHtml(getWho(calEvent, t)), + textToHtml(getDescription(calEvent, t)), + textToHtml(getAdditionalNotes(calEvent, t)), + textToHtml(getUserFieldsResponses(calEvent, t)), + includeAppStatus ? textToHtml(getAppsStatus(calEvent, t)) : "", + manageLinkHtml, + calEvent.paymentInfo + ? `

${t("pay_now")}: ${ + calEvent.paymentInfo.link + }

` + : "", + ] + .filter(Boolean) // Remove empty strings + .join("\n"); // Single newline between sections + + return parts.trim(); +}; + export const getRichDescription = ( calEvent: RichDescriptionCalEvent, t_?: TFunction /*, attendee?: Person*/, diff --git a/packages/lib/EventManager.ts b/packages/lib/EventManager.ts index fd25434b77..5fe836b1cf 100644 --- a/packages/lib/EventManager.ts +++ b/packages/lib/EventManager.ts @@ -7,7 +7,7 @@ import type { z } from "zod"; import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter"; import { appKeysSchema as calVideoKeysSchema } from "@calcom/app-store/dailyvideo/zod"; -import { getLocationFromApp, MeetLocationType } from "@calcom/app-store/locations"; +import { getLocationFromApp, MeetLocationType, MSTeamsLocationType } from "@calcom/app-store/locations"; import getApps from "@calcom/app-store/utils"; import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { getUid } from "@calcom/lib/CalEventParser"; @@ -251,6 +251,32 @@ export default class EventManager { return matches; } + private updateMSTeamsVideoCallData( + evt: CalendarEvent, + results: Array>> + ) { + const office365CalendarWithTeams = results.find( + (result) => result.type === "office365_calendar" && result.success && result.createdEvent?.url + ); + if (office365CalendarWithTeams) { + evt.videoCallData = { + type: "office365_video", + id: office365CalendarWithTeams.createdEvent?.id, + password: "", + url: office365CalendarWithTeams.createdEvent?.url, + }; + if (evt.location && evt.responses) { + evt.responses["location"] = { + ...(evt.responses["location"] ?? {}), + value: { + optionValue: "", + value: evt.location, + }, + }; + } + } + } + /** * Takes a CalendarEvent and creates all necessary integration entries for it. * When a video integration is chosen as the event's location, a video integration @@ -299,12 +325,15 @@ export default class EventManager { evt["conferenceCredentialId"] = undefined; } } + const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null; + const isMSTeamsWithOutlookCalendar = evt.location === MSTeamsLocationType && mainHostDestinationCalendar?.integration === "office365_calendar"; const results: Array>> = []; // If and only if event type is a dedicated meeting, create a dedicated video meeting. - if (isDedicated) { + // If the event is a Microsoft Teams meeting with Outlook Calendar, do not create a MSTeams video event, create calendar event will take care. + if (isDedicated && !isMSTeamsWithOutlookCalendar) { const result = await this.createVideoEvent(evt); if (result?.createdEvent) { @@ -331,6 +360,10 @@ export default class EventManager { // Create the calendar event with the proper video call data results.push(...(await this.createAllCalendarEvents(clonedCalEvent))); + if (evt.location === MSTeamsLocationType) { + this.updateMSTeamsVideoCallData(evt, results); + } + // Since the result can be a new calendar event or video event, we have to create a type guard // https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates const isCalendarResult = ( @@ -405,6 +438,10 @@ export default class EventManager { const calendarReference = booking.references.find((reference) => reference.type.includes("_calendar")); if (calendarReference) { results.push(...(await this.updateAllCalendarEvents(evt, booking))); + + if (evt.location === MSTeamsLocationType) { + this.updateMSTeamsVideoCallData(evt, results); + } } const referencesToCreate = results.map((result) => {