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>
This commit is contained in:
1 parent
ce6969d058
commit
242f86891a
7 files changed
+166
-10
No files matched your search
@@ -97,6 +97,7 @@ export const AppDependencyComponent = ({
|
||||
{t("this_app_requires_connected_account", {
|
||||
appName,
|
||||
dependencyName: dependency.name,
|
||||
interpolation: { escapeValue: false },
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
@@ -112,7 +113,11 @@ export const AppDependencyComponent = ({
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">
|
||||
{t("this_app_requires_connected_account", { appName, dependencyName: dependency.name })}
|
||||
{t("this_app_requires_connected_account", {
|
||||
appName,
|
||||
dependencyName: dependency.name,
|
||||
interpolation: { escapeValue: false },
|
||||
})}
|
||||
</span>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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<NewCalendarEventType & { iCalUId: string }>(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<NewCalendarEventType> {
|
||||
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<Event>(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<NewCalendarEventType & { iCalUId: string }>(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)}<hr>
|
||||
${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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
}
|
||||
},
|
||||
"dirName": "office365video",
|
||||
"dependencies": ["office365-calendar"],
|
||||
"concurrentMeetings": true,
|
||||
"isOAuth": true
|
||||
}
|
||||
@@ -314,6 +314,64 @@ type RichDescriptionCalEvent = Parameters<typeof getCancellationReason>[0] &
|
||||
Parameters<typeof getManageLink>[0] &
|
||||
Pick<CalendarEvent, "organizer" | "paymentInfo">;
|
||||
|
||||
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 `<p><strong>${line}</strong></p>`;
|
||||
}
|
||||
return `<p>${line}</p>`;
|
||||
})
|
||||
.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 `<p><strong>${textWithoutLink}</strong> <a href="${lastWord}">Click here</a></p>`;
|
||||
}
|
||||
return `<p>${manageLinkText}</p>`;
|
||||
})()
|
||||
: "";
|
||||
|
||||
// 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
|
||||
? `<p><strong>${t("pay_now")}:</strong> <a href="${calEvent.paymentInfo.link}">${
|
||||
calEvent.paymentInfo.link
|
||||
}</a></p>`
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean) // Remove empty strings
|
||||
.join("\n"); // Single newline between sections
|
||||
|
||||
return parts.trim();
|
||||
};
|
||||
|
||||
export const getRichDescription = (
|
||||
calEvent: RichDescriptionCalEvent,
|
||||
t_?: TFunction /*, attendee?: Person*/,
|
||||
|
||||
@@ -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<EventResult<Exclude<Event, AdditionalInformation>>>
|
||||
) {
|
||||
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<EventResult<Exclude<Event, AdditionalInformation>>> = [];
|
||||
|
||||
// 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) => {
|
||||
|
||||
Reference in new issue
Block a user