Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: google meet no show #17271

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ export default function EditOAuthClientWebhooks() {
value: WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
label: "after_guests_cal_video_no_show",
},
{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should these be separate per video type? Seems like it should be AFTER_HOSTS_NO_SHOW and AFTER_GUESTS_NO_SHOW no matter which video type they used.

value: WebhookTriggerEvents.AFTER_HOSTS_GOOGLE_MEET_NO_SHOW,
label: "after_hosts_google_meet_no_show",
},
{
value: WebhookTriggerEvents.AFTER_GUESTS_GOOGLE_MEET_NO_SHOW,
label: "after_guests_google_meet_no_show",
},
]}
onSubmit={async (data) => {
try {
Expand Down
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@
"@babel/core": "^7.19.6",
"@calcom/config": "*",
"@calcom/types": "*",
"@google-apps/meet": "^0.3.0",
"@microsoft/microsoft-graph-types-beta": "0.15.0-preview",
"@playwright/test": "^1.45.3",
"@testing-library/react": "^13.3.0",
Expand Down Expand Up @@ -177,6 +178,7 @@
"deasync": "^0.1.30",
"detect-port": "^1.3.0",
"env-cmd": "^10.1.0",
"google-auth-library": "^9.14.2",
"module-alias": "^2.2.2",
"msw": "^0.42.3",
"node-html-parser": "^6.1.10",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -1234,6 +1234,8 @@
"event_cancelled_trigger": "when event is canceled",
"after_hosts_cal_video_no_show": "After hosts don't join cal video",
"after_guests_cal_video_no_show": "After guests don't join cal video",
"after_hosts_google_meet_no_show": "After hosts don't join google meet",
"after_guests_google_meet_no_show": "After guests don't join google meet",
"new_event_trigger": "when new event is booked",
"email_host_action": "send email to host",
"email_attendee_action": "send email to attendees",
Expand Down
90 changes: 89 additions & 1 deletion packages/app-store/googlecalendar/lib/CalendarService.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { protos } from "@google-apps/meet";
import { ConferenceRecordsServiceClient, SpacesServiceClient } from "@google-apps/meet";
import type { Prisma } from "@prisma/client";
import { GoogleAuth, OAuth2Client } from "google-auth-library";
import type { calendar_v3 } from "googleapis";
import { google } from "googleapis";
import { RRule } from "rrule";
Expand Down Expand Up @@ -42,6 +45,10 @@ interface GoogleCalError extends Error {
code?: number;
}

export interface ParticipantWithEmail extends protos.google.apps.meet.v2.IParticipant {
email?: string;
}

const ONE_MINUTE_MS = 60 * 1000;
const CACHING_TIME = ONE_MINUTE_MS;

Expand Down Expand Up @@ -334,6 +341,7 @@ export default class GoogleCalendarService implements Calendar {
conferenceDataVersion: 1,
sendUpdates: "none",
});

event = eventResponse.data;
if (event.recurrence) {
if (event.recurrence.length > 0) {
Expand Down Expand Up @@ -663,6 +671,86 @@ export default class GoogleCalendarService implements Calendar {
}
}

async getMeetParticipants(videoCallUrl: string | null): Promise<ParticipantWithEmail[][]> {
const { token } = await this.oAuthManagerInstance.getTokenObjectOrFetch();
if (!token) {
throw new Error("Invalid grant for Google Calendar app");
}

const googleAuth = new GoogleAuth({
authClient: new OAuth2Client({
credentials: {
access_token: token.access_token,
},
}),
});

Comment on lines +680 to +687
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was throwing type error when i used the Google auth client in this class so i had to create this one

const meetClient = new ConferenceRecordsServiceClient({
auth: googleAuth as ConferenceRecordsServiceClient["auth"],
});

const spacesClient = new SpacesServiceClient({
auth: googleAuth as SpacesServiceClient["auth"],
});

const meetingCode = videoCallUrl ? new URL(videoCallUrl).pathname.split("/").pop() : null;

const spaceInfo = await spacesClient.getSpace({ name: `spaces/${meetingCode}` });
const spaceName = spaceInfo[0].name;

const conferenceRecords = [];
for await (const response of meetClient.listConferenceRecordsAsync()) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

meetClient.listConferenceRecordsAsync() returns all conference records which were created by the user. conference record refers to a meeting.

if (response.space === spaceName) {
conferenceRecords.push(response);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we only want the conference record on our meeting url

}
}

const participantsByConferenceRecord = await Promise.all(
conferenceRecords.map(async (conferenceRecord) => {
const participants = [];
for await (const participant of meetClient.listParticipantsAsync({ parent: conferenceRecord.name })) {
participants.push(participant);
}
return participants;
})
);

const participantsWithEmails = await Promise.all(
participantsByConferenceRecord.map(async (participants) => {
return Promise.all(
participants.map(async (participant) => {
try {
const response = await fetch(
`https://people.googleapis.com/v1/people/${
participant.signedinUser?.user?.split("/")[1]
}?personFields=emailAddresses`,
Comment on lines +723 to +726
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetch email here by id

{
headers: {
Authorization: `Bearer ${token.access_token}`,
Accept: "application/json",
},
}
);

const data = await response.json();
const emailAddresses = data.emailAddresses;

return {
...participant,
email: emailAddresses ? emailAddresses[0].value : undefined,
};
} catch (err) {
console.error("Error fetching email for participant:", err);
return participant;
}
})
);
})
);

return participantsWithEmails;
}

async listCalendars(): Promise<IntegrationCalendar[]> {
this.log.debug("Listing calendars");
const calendar = await this.authedCalendar();
Expand Down Expand Up @@ -693,7 +781,7 @@ export default class GoogleCalendarService implements Calendar {
}
}

class MyGoogleAuth extends google.auth.OAuth2 {
export class MyGoogleAuth extends google.auth.OAuth2 {
constructor(client_id: string, client_secret: string, redirect_uri: string) {
super(client_id, client_secret, redirect_uri);
}
Expand Down
15 changes: 13 additions & 2 deletions packages/app-store/googlecalendar/lib/constants.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
export const SCOPE_USERINFO_PROFILE = "https://www.googleapis.com/auth/userinfo.profile";
export const SCOPE_CALENDAR_READONLY = "https://www.googleapis.com/auth/calendar.readonly";
export const SCOPE_CALENDAR_EVENT = "https://www.googleapis.com/auth/calendar.events";
export const GOOGLE_MEET_API = "https://www.googleapis.com/auth/meetings.space.readonly";
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need this scope to fetch details about meeting and fetch participants

export const SCOPE_USERINFO_EMAIL = "https://www.googleapis.com/auth/userinfo.email";

export const REQUIRED_SCOPES = [SCOPE_CALENDAR_READONLY, SCOPE_CALENDAR_EVENT];
export const SCOPE_USER_CONTACTS = "https://www.googleapis.com/auth/contacts.readonly";
export const SCOPE_OTHER_USER_CONTACTS = "https://www.googleapis.com/auth/contacts.other.readonly";
Comment on lines +5 to +8
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need these scope to fetch user's workspace accounts email.


export const SCOPES = [...REQUIRED_SCOPES, SCOPE_USERINFO_PROFILE];
export const REQUIRED_SCOPES = [SCOPE_CALENDAR_READONLY, SCOPE_CALENDAR_EVENT, GOOGLE_MEET_API];

export const SCOPES = [
...REQUIRED_SCOPES,
SCOPE_USERINFO_EMAIL,
SCOPE_USERINFO_PROFILE,
SCOPE_USER_CONTACTS,
SCOPE_OTHER_USER_CONTACTS,
];
3 changes: 3 additions & 0 deletions packages/features/bookings/lib/handleConfirmation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export async function handleConfirmation(args: {
booking: {
startTime: Date;
id: number;
location: string | null;
eventType: {
currency: string;
description: string | null;
Expand Down Expand Up @@ -401,12 +402,14 @@ export async function handleConfirmation(args: {
booking: {
startTime: booking.startTime,
id: booking.id,
location: booking.location,
},
triggerForUser,
organizerUser: { id: booking.userId },
eventTypeId: booking.eventTypeId,
teamId,
orgId,
destinationCalendars: evt.destinationCalendar,
});

const eventTypeInfo: EventTypeInfo = {
Expand Down
8 changes: 5 additions & 3 deletions packages/features/bookings/lib/handleNewBooking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
OrganizerDefaultConferencingAppType,
getLocationValueForDB,
} from "@calcom/app-store/locations";
import { DailyLocationType } from "@calcom/app-store/locations";
import { getAppFromSlug } from "@calcom/app-store/utils";
import EventManager from "@calcom/core/EventManager";
import { getEventName } from "@calcom/core/event";
Expand Down Expand Up @@ -1249,6 +1248,7 @@ async function handler(
} else if (isConfirmedByDefault) {
// Use EventManager to conditionally use all needed integrations.
const createManager = await eventManager.create(evt);
console.log("createManager", JSON.stringify(createManager));
if (evt.location) {
booking.location = evt.location;
}
Expand Down Expand Up @@ -1647,17 +1647,19 @@ async function handler(
}

try {
if (isConfirmedByDefault && (booking.location === DailyLocationType || booking.location?.trim() === "")) {
if (isConfirmedByDefault) {
await scheduleNoShowTriggers({
booking: { startTime: booking.startTime, id: booking.id },
booking: { startTime: booking.startTime, id: booking.id, location: booking.location },
triggerForUser,
organizerUser: { id: organizerUser.id },
eventTypeId,
teamId,
orgId,
destinationCalendars: evt.destinationCalendar,
});
}
} catch (error) {
console.log("error", error);
loggerWithEventDetails.error("Error while scheduling no show triggers", JSON.stringify({ error }));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import type { DestinationCalendar } from "@prisma/client";

import { DailyLocationType } from "@calcom/app-store/locations";
import dayjs from "@calcom/dayjs";
import tasker from "@calcom/features/tasker";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
Expand All @@ -7,28 +10,57 @@ type ScheduleNoShowTriggersArgs = {
booking: {
startTime: Date;
id: number;
location: string | null;
};
triggerForUser?: number | true | null;
organizerUser: { id: number | null };
eventTypeId: number | null;
teamId?: number | null;
orgId?: number | null;
destinationCalendars?: DestinationCalendar[] | null;
};

export const scheduleNoShowTriggers = async (args: ScheduleNoShowTriggersArgs) => {
const { booking, triggerForUser, organizerUser, eventTypeId, teamId, orgId } = args;
const { booking, triggerForUser, organizerUser, eventTypeId, teamId, orgId, destinationCalendars } = args;

const isDailyVideoLocation = booking.location === DailyLocationType || booking.location?.trim() === "";
const isGoogleMeetLocation = booking.location === "integrations:google:meet";

if (!isGoogleMeetLocation && !isDailyVideoLocation) return;

// Add task for automatic no show in cal video
const noShowPromises: Promise<any>[] = [];

const subscribersHostsNoShowStarted = await getWebhooks({
const hostNoShowTriggerEvent = isDailyVideoLocation
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My previous comment applies here. This list will continue to grow larger and larger for the more video integrations we detect no shows for. Do we absolutely have to do this?

? WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW
: WebhookTriggerEvents.AFTER_HOSTS_GOOGLE_MEET_NO_SHOW;

const guestNoShowTriggerEvent = isDailyVideoLocation
? WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW
: WebhookTriggerEvents.AFTER_GUESTS_GOOGLE_MEET_NO_SHOW;

const subscribersHostsNoShowStartedPromises = getWebhooks({
userId: triggerForUser ? organizerUser.id : null,
eventTypeId,
triggerEvent: hostNoShowTriggerEvent,
teamId,
orgId,
});
const subscribersGuestsNoShowStartedPromises = getWebhooks({
userId: triggerForUser ? organizerUser.id : null,
eventTypeId,
triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
triggerEvent: guestNoShowTriggerEvent,
teamId,
orgId,
});

const [subscribersHostsNoShowStarted, subscribersGuestsNoShowStarted] = await Promise.all([
subscribersHostsNoShowStartedPromises,
subscribersGuestsNoShowStartedPromises,
]);

// TODO: is this correct?
const destinationCalendar = destinationCalendars?.find((cal) => cal.userId === organizerUser.id);

Comment on lines +61 to +63
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need the destination calendar of the organizer (that created the google meet url).

noShowPromises.push(
...subscribersHostsNoShowStarted.map((webhook) => {
if (booking?.startTime && webhook.time && webhook.timeUnit) {
Expand All @@ -38,10 +70,11 @@ export const scheduleNoShowTriggers = async (args: ScheduleNoShowTriggersArgs) =
return tasker.create(
"triggerHostNoShowWebhook",
{
triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
triggerEvent: hostNoShowTriggerEvent,
bookingId: booking.id,
// Prevents null values from being serialized
webhook: { ...webhook, time: webhook.time, timeUnit: webhook.timeUnit },
destinationCalendar,
},
{ scheduledAt }
);
Expand All @@ -50,14 +83,6 @@ export const scheduleNoShowTriggers = async (args: ScheduleNoShowTriggersArgs) =
})
);

const subscribersGuestsNoShowStarted = await getWebhooks({
userId: triggerForUser ? organizerUser.id : null,
eventTypeId,
triggerEvent: WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
teamId,
orgId,
});

noShowPromises.push(
...subscribersGuestsNoShowStarted.map((webhook) => {
if (booking?.startTime && webhook.time && webhook.timeUnit) {
Expand All @@ -68,10 +93,11 @@ export const scheduleNoShowTriggers = async (args: ScheduleNoShowTriggersArgs) =
return tasker.create(
"triggerGuestNoShowWebhook",
{
triggerEvent: WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
triggerEvent: guestNoShowTriggerEvent,
bookingId: booking.id,
// Prevents null values from being serialized
webhook: { ...webhook, time: webhook.time, timeUnit: webhook.timeUnit },
destinationCalendar,
},
{ scheduledAt }
);
Expand Down
Loading
Loading