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:
VijayandAnik Dhabal Babu authored and GitHub committed 2025-08-11 08:11:45 +05:30
1 parent ce6969d058
commit 242f86891a
7 files changed
+166 -10

No files matched your search

+6 -1
View File
@@ -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>
+2
View File
@@ -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
}
+58
View File
@@ -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*/,
+39 -2
View File
@@ -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) => {