Files
Joe Au-YeungGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Udit TakkarAnik Dhabal Babu
08edd4f361 chore: Add logging to msteams (#23684)
* Add logging

* fix: Add response status checking to MS Teams adapter error handling

- Add response.ok checks before parsing JSON in createMeeting and updateMeeting
- Remove console.log statements that could leak credential information
- Ensure error handling matches original handleErrorsRaw behavior for tests

Co-Authored-By: [email protected] <[email protected]>

* Fix logs

* Revert "fix: Add response status checking to MS Teams adapter error handling"

This reverts commit 35820d21e1a54d9e04d83fd5f53584d64a5459b6.

* fix: Add response status checking to MS Teams adapter error handling

- Check response.ok before parsing JSON in createMeeting and updateMeeting
- Re-throw HttpError instances instead of wrapping them in catch blocks
- This ensures HTTP errors (like 500) are properly propagated

Co-Authored-By: unknown <>

* fix: Correct log message in updateMeeting to say 'updating' instead of 'creating'

Co-Authored-By: unknown <>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Udit Takkar <[email protected]>
Co-authored-by: Anik Dhabal Babu <[email protected]>
2026-01-11 10:34:02 +05:30

322 lines
11 KiB
TypeScript

import { z } from "zod";
import { triggerDelegationCredentialErrorWebhook } from "@calcom/features/webhooks/lib/triggerDelegationCredentialErrorWebhook";
import {
CalendarAppDelegationCredentialConfigurationError,
CalendarAppDelegationCredentialInvalidGrantError,
} from "@calcom/lib/CalendarAppError";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { CredentialForCalendarServiceWithTenantId } from "@calcom/types/Credential";
import type { PartialReference } from "@calcom/types/EventManager";
import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter";
import getParsedAppKeysFromSlug from "../../_utils/getParsedAppKeysFromSlug";
import { OAuthManager } from "../../_utils/oauth/OAuthManager";
import { oAuthManagerHelper } from "../../_utils/oauth/oAuthManagerHelper";
import { OFFICE365_VIDEO_SCOPES } from "../api/add";
import config from "../config.json";
/** @link https://docs.microsoft.com/en-us/graph/api/application-post-onlinemeetings?view=graph-rest-1.0&tabs=http#response */
export interface TeamsEventResult {
creationDateTime: string;
startDateTime: string;
endDateTime: string;
id: string;
joinWebUrl: string;
subject: string;
}
const o365VideoAppKeysSchema = z.object({
client_id: z.string(),
client_secret: z.string(),
});
const getO365VideoAppKeys = async () => {
return getParsedAppKeysFromSlug(config.slug, o365VideoAppKeysSchema);
};
const TeamsVideoApiAdapter = (credential: CredentialForCalendarServiceWithTenantId): VideoApiAdapter => {
const log = logger.getSubLogger({ prefix: ["TeamsVideoApiAdapter"] });
let azureUserId: string | null;
const tokenResponse = oAuthManagerHelper.getTokenObjectFromCredential(credential);
async function triggerDelegationCredentialError(error: Error): Promise<void> {
if (credential.userId && credential.user && credential.appId && credential.delegatedToId) {
await triggerDelegationCredentialErrorWebhook({
error,
credential: {
id: credential.id,
type: credential.type,
appId: credential.appId,
},
user: {
id: credential.userId ?? 0,
email: credential.user.email,
},
delegationCredentialId: credential.delegatedToId,
});
}
}
const auth = new OAuthManager({
credentialSyncVariables: oAuthManagerHelper.credentialSyncVariables,
resourceOwner: {
type: "user",
id: credential.userId,
},
appSlug: config.slug,
currentTokenObject: tokenResponse,
fetchNewTokenObject: async ({ refreshToken }: { refreshToken: string | null }) => {
const isDelegated = Boolean(credential?.delegatedTo);
if (!isDelegated && !refreshToken) {
return null;
}
const credentials = isDelegated
? {
client_id: credential?.delegatedTo?.serviceAccountKey?.client_id,
client_secret: credential?.delegatedTo?.serviceAccountKey?.private_key,
}
: await getO365VideoAppKeys();
if (isDelegated && (!credentials.client_id || !credentials.client_secret)) {
const error = new CalendarAppDelegationCredentialConfigurationError(
"Delegation credential without clientId or Secret"
);
await triggerDelegationCredentialError(error);
throw error;
}
const url = await getAuthUrl(isDelegated, credential?.delegatedTo?.serviceAccountKey?.tenant_id);
const scope = isDelegated ? "https://graph.microsoft.com/.default" : OFFICE365_VIDEO_SCOPES.join(" ");
const params: Record<string, string> = {
scope,
client_id: credentials.client_id || "",
client_secret: credentials.client_secret || "",
grant_type: isDelegated ? "client_credentials" : "refresh_token",
...(isDelegated ? {} : { refresh_token: refreshToken ?? "" }),
};
return await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(params),
});
},
isTokenObjectUnusable: async function () {
// TODO: Implement this. As current implementation of CalendarService doesn't handle it. It hasn't been handled in the OAuthManager implementation as well.
// This is a placeholder for future implementation.
return null;
},
isAccessTokenUnusable: async function () {
// TODO: Implement this
return null;
},
invalidateTokenObject: () => oAuthManagerHelper.invalidateCredential(credential.id),
expireAccessToken: () => oAuthManagerHelper.markTokenAsExpired(credential),
updateTokenObject: (tokenObject) => {
if (!credential.delegatedTo) {
return oAuthManagerHelper.updateTokenObject({ tokenObject, credentialId: credential.id });
}
return Promise.resolve();
},
});
async function getAuthUrl(delegatedTo: boolean, tenantId?: string): Promise<string> {
if (delegatedTo) {
if (!tenantId) {
const error = new CalendarAppDelegationCredentialInvalidGrantError(
"Invalid DelegationCredential Settings: tenantId is missing"
);
await triggerDelegationCredentialError(error);
throw error;
}
return `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
}
return "https://login.microsoftonline.com/common/oauth2/v2.0/token";
}
const translateEvent = (event: CalendarEvent) => {
return {
startDateTime: event.startTime,
endDateTime: event.endTime,
subject: event.title,
};
};
async function getAzureUserId(credential: CredentialForCalendarServiceWithTenantId) {
if (azureUserId) return azureUserId;
const isDelegated = Boolean(credential?.delegatedTo);
if (!isDelegated) return null;
const url = await getAuthUrl(isDelegated, credential?.delegatedTo?.serviceAccountKey?.tenant_id);
const delegationCredentialClientId = credential.delegatedTo?.serviceAccountKey?.client_id;
const delegationCredentialClientSecret = credential.delegatedTo?.serviceAccountKey?.private_key;
if (!delegationCredentialClientId || !delegationCredentialClientSecret) {
const error = new CalendarAppDelegationCredentialConfigurationError(
"Delegation credential without clientId or Secret"
);
await triggerDelegationCredentialError(error);
throw error;
}
const loginResponse = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
scope: "https://graph.microsoft.com/.default",
client_id: delegationCredentialClientId,
grant_type: "client_credentials",
client_secret: delegationCredentialClientSecret,
}),
});
const clonedResponse = loginResponse.clone();
const parsedLoginResponse = await clonedResponse.json();
const token = parsedLoginResponse?.access_token;
const oauthClientIdAliasRegex = /\+[a-zA-Z0-9]{25}/;
const email = credential?.user?.email.replace(oauthClientIdAliasRegex, "");
const encodedFilter = encodeURIComponent(`mail eq '${email}'`);
const queryParams = `$filter=${encodedFilter}`;
const response = await fetch(`https://graph.microsoft.com/v1.0/users?${queryParams}`, {
method: "GET",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Bearer ${token}`,
},
});
const parsedBody = await response.json();
if (!parsedBody?.value?.[0]?.id) {
const error = new CalendarAppDelegationCredentialInvalidGrantError(
"User might not exist in Microsoft Azure Active Directory"
);
await triggerDelegationCredentialError(error);
throw error;
}
azureUserId = parsedBody.value[0].id;
return azureUserId;
}
async function getUserEndpoint(): Promise<string> {
const azureUserId = await getAzureUserId(credential);
return azureUserId
? `https://graph.microsoft.com/v1.0/users/${azureUserId}`
: "https://graph.microsoft.com/v1.0/me";
}
// Since the meeting link is not tied to an event we only need the create and update functions
return {
getAvailability: () => {
return Promise.resolve([]);
},
updateMeeting: async (bookingRef: PartialReference, event: CalendarEvent) => {
try {
const response = await auth.requestRaw({
url: `${await getUserEndpoint()}/onlineMeetings`,
options: {
method: "POST",
body: JSON.stringify(translateEvent(event)),
},
});
if (!response.ok) {
throw new HttpError({
statusCode: response.status,
message: response.statusText,
});
}
const resultString = await response.text();
const resultObject = JSON.parse(resultString);
return Promise.resolve({
type: "office365_video",
id: resultObject.id,
password: "",
url: resultObject.joinWebUrl || resultObject.joinUrl,
});
} catch (error) {
log.error(`Error updating MS Teams meeting for booking ${event.uid}`, error);
if (error instanceof HttpError) {
throw error;
}
throw new HttpError({
statusCode: 500,
message: `Error updating MS Teams meeting for booking ${event.uid}`,
});
}
},
deleteMeeting:() => {
return Promise.resolve([]);
},
createMeeting: async (event: CalendarEvent): Promise<VideoCallData> => {
const url = `${await getUserEndpoint()}/onlineMeetings`;
try {
const response = await auth.requestRaw({
url,
options: {
method: "POST",
body: JSON.stringify(translateEvent(event)),
},
});
if (!response.ok) {
throw new HttpError({
statusCode: response.status,
message: response.statusText,
});
}
const resultString = await response.text();
const resultObject = JSON.parse(resultString);
if (!resultObject.id || !resultObject.joinUrl || !resultObject.joinWebUrl) {
throw new HttpError({
statusCode: 500,
message: `Error creating MS Teams meeting: ${resultObject.error?.message || "missing required fields in response"}`,
});
}
log.debug("Teams meeting created", { meetingId: resultObject.id });
return Promise.resolve({
type: "office365_video",
id: resultObject.id,
password: "",
url: resultObject.joinWebUrl || resultObject.joinUrl,
});
} catch (error) {
log.error(`Error creating MS Teams meeting for booking ${event.uid}`, error);
if (error instanceof HttpError) {
throw error;
}
throw new HttpError({
statusCode: 500,
message: `Error creating MS Teams meeting for booking ${event.uid}`,
});
}
},
};
};
export default TeamsVideoApiAdapter;