import { defineAction, fail } from "@agent-native/core/action"; import type { ActionRunContext } from "@agent-native/core/action"; import { buildDeepLink } from "@agent-native/core/server"; import { track } from "@agent-native/core/tracking"; import { z } from "zod"; import { normalizeGuestNotificationMessage, sendEventGuestNotificationNote, } from "../server/lib/event-guest-notifications.js"; import { prepareZoomMeetingPatch } from "../server/lib/event-video-conferencing.js"; import * as googleCalendar from "../server/lib/google-calendar.js"; import type { CalendarEvent } from "../shared/api.js"; import { isCalendarEventOrganizer } from "../shared/event-permissions.js"; import { availabilityInput, attachmentsInput, attendeesInput, buildReminderOverrides, buildStatusEventFields, cliBoolean, googleColorIdInput, normalizeAttendees, googleEventResultId, normalizeWritableGoogleEventId, normalizeRecurrence, reminderMethodInput, reminderMinutesInput, remindersInput, requireActionUserEmail, resolveGoogleEventAccountEmail, resolveOwnedAccountEmail, validateEventTimeOrder, validateStatusEventTiming, visibilityInput, workingLocationTypeInput, } from "./event-action-helpers.js"; function mergeAttendees( existing: CalendarEvent["attendees"] | undefined, additions: CalendarEvent["attendees"] | undefined, ): CalendarEvent["attendees"] | undefined { if (!additions || additions.length === 0) return existing; const merged = new Map< string, NonNullable[number] >(); for (const attendee of existing ?? []) { const email = attendee.email?.trim(); if (!email) continue; merged.set(email.toLowerCase(), { ...attendee, email }); } for (const attendee of additions) { const email = attendee.email?.trim(); if (!email || !email.includes("@")) continue; const key = email.toLowerCase(); const current = merged.get(key); merged.set(key, { ...current, email, displayName: attendee.displayName ?? current?.displayName, photoUrl: attendee.photoUrl ?? current?.photoUrl, optional: attendee.optional === true ? true : attendee.optional === false ? undefined : current?.optional, }); } return Array.from(merged.values()); } function namesGuests(value: Parameters[0]): boolean { return (normalizeAttendees(value)?.length ?? 0) > 0; } function workingLocationTitle( properties: NonNullable, ): string { if (properties.type === "homeOffice") return "Home"; if (properties.type === "officeLocation") { return properties.officeLocation?.label || "Office"; } return properties.customLocation?.label || "Working location"; } function eventChange(id: string, title: string) { return { verb: "updated" as const, kind: "calendar-event", title: title.trim().slice(0, 180) || "Event", url: buildDeepLink({ app: "calendar", view: "calendar", params: { eventId: id }, }), }; } export default defineAction({ searchHints: ["reschedule", "move a meeting", "change meeting time"], mcpTool: true, mcpAnnotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true, }, description: "Update a Google Calendar event. Supports moving an existing event between connected Google account calendars, as well as title, description, location, time, event color, attachments, reminders, and recurrence rules such as RRULE:FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR.", schema: z.object({ id: z .string() .describe('Google Calendar event id, with or without "google-" prefix'), accountEmail: z .string() .optional() .describe( "Connected Google account email from list-events/search-events", ), targetAccountEmail: z .string() .optional() .describe( "Move this event to another connected Google account's primary calendar. Pass accountEmail as the current event account. Moving creates the event on the destination and removes it from the source.", ), title: z.string().optional().describe("New event title"), description: z.string().optional().describe("New event description"), location: z.string().optional().describe("New event location"), workingLocationType: workingLocationTypeInput.describe( "For existing working-location events: homeOffice, officeLocation, or customLocation. Google Calendar event types cannot be changed after creation.", ), workingLocationLabel: z .string() .optional() .describe( "For existing working-location events: label shown in Google Calendar.", ), start: z.string().optional().describe("New start time/date as ISO string"), end: z.string().optional().describe("New end time/date as ISO string"), startTimeZone: z .string() .optional() .describe("IANA timezone for the event start, e.g. America/New_York"), endTimeZone: z .string() .optional() .describe("IANA timezone for the event end, e.g. America/New_York"), allDay: cliBoolean.optional().describe("Whether the event is all-day"), transparency: availabilityInput.describe( "Google Calendar availability: opaque blocks time (Busy), transparent does not block time (Free). Existing working-location events are always sent to Google as transparent.", ), visibility: visibilityInput.describe( "Google Calendar visibility: default, public, private, or confidential. Existing working-location events are always sent to Google as public.", ), status: z .enum(["confirmed", "tentative", "cancelled"]) .optional() .describe("Google Calendar event status."), remindersUseDefault: cliBoolean .optional() .describe( "Whether to use calendar default reminders. Set false with no reminders to clear alert notifications.", ), reminders: remindersInput.describe( "Custom reminder overrides, max 5, such as [{method:'popup', minutes:10}].", ), attachments: attachmentsInput.describe( "Replace Google Calendar attachments, max 25. Pass [] to clear.", ), colorId: googleColorIdInput.describe( "Google Calendar event color id, 1 through 11.", ), reminderMinutes: reminderMinutesInput.describe( "Convenience field for a single reminder in minutes before the event.", ), reminderMethod: reminderMethodInput.describe( "Reminder method for reminderMinutes. Defaults to popup.", ), addGoogleMeet: cliBoolean .optional() .describe("Generate and attach a Google Meet link to the event"), removeGoogleMeet: cliBoolean .optional() .describe("Remove an attached Google Meet link from the event"), addZoom: cliBoolean .optional() .describe( "Create and attach a Zoom meeting link to the event. Requires Zoom to be connected in Settings.", ), recurrence: z .union([z.string(), z.array(z.string())]) .optional() .describe( "Google recurrence rules. For weekdays only, use RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR. Pass an empty string or [] to clear recurrence.", ), scope: z .enum(["single", "all"]) .optional() .describe( "For recurring events, use single for just this occurrence or all for the entire series.", ), attendees: attendeesInput .optional() .describe( "Replace the event's attendee list. Accepts an array of {email, displayName?, optional?} or a comma-separated string of emails. Pass an empty array to clear all attendees. Set optional:true to mark a guest optional.", ), addAttendees: attendeesInput .optional() .describe( "Add invitees without dropping or resetting existing attendees. Accepts an array of {email, displayName?, optional?} or a comma-separated string of emails. Set optional:true to mark a newly added guest optional.", ), sendUpdates: z .enum(["all", "none"]) .optional() .describe("Whether Google should notify attendees"), notificationMessage: z .string() .optional() .describe( "Optional note to send to guests as a companion email when notifying attendees about the update. Google Calendar API notifications only accept sendUpdates.", ), }), toolCallable: false, needsApproval: async ({ id, accountEmail: requestedAccountEmail, scope, sendUpdates, notificationMessage, targetAccountEmail, attendees, addAttendees, }) => { if (targetAccountEmail !== undefined || !!notificationMessage?.trim()) { return true; } if (sendUpdates === "none") return false; if (sendUpdates === undefined) return namesGuests(addAttendees); if (namesGuests(attendees) || namesGuests(addAttendees)) return true; // An empty replacement can still send cancellation notices to removed guests. const ownerEmail = requireActionUserEmail(); const accountEmail = await resolveOwnedAccountEmail( resolveGoogleEventAccountEmail(id, requestedAccountEmail), ownerEmail, ); let event = await googleCalendar.getEvent( normalizeWritableGoogleEventId(id), { ownerEmail, accountEmail, }, ); if (scope === "all" && event.recurringEventId) { event = await googleCalendar.getEvent(event.recurringEventId, { ownerEmail, accountEmail, }); } return (event.attendees?.length ?? 0) > 0; }, run: async (args, actionContext?: ActionRunContext) => { const ownerEmail = requireActionUserEmail(); if (args.addGoogleMeet && args.addZoom) { throw new Error("Choose either Google Meet or Zoom, not both."); } if (args.addGoogleMeet && args.removeGoogleMeet) { throw new Error( "Choose either adding or removing Google Meet, not both.", ); } if (args.attendees !== undefined && args.addAttendees !== undefined) { throw new Error("Use either attendees or addAttendees, not both."); } if (!(await googleCalendar.isConnected(ownerEmail))) { throw new Error( "Google Calendar not connected. Connect via Settings first.", ); } const accountEmail = await resolveOwnedAccountEmail( resolveGoogleEventAccountEmail(args.id, args.accountEmail), ownerEmail, ); const googleEventId = normalizeWritableGoogleEventId(args.id); const targetAccountEmail = args.targetAccountEmail !== undefined ? await resolveOwnedAccountEmail(args.targetAccountEmail, ownerEmail) : undefined; const hasAccountMove = targetAccountEmail !== undefined; const recurrence = normalizeRecurrence(args.recurrence); const guestNotificationMessage = normalizeGuestNotificationMessage( args.notificationMessage, ); const reminderFields = buildReminderOverrides({ reminders: args.reminders, reminderMinutes: args.reminderMinutes, reminderMethod: args.reminderMethod, useDefaultReminders: args.remindersUseDefault, }); const hasWorkingLocationPatch = args.workingLocationType !== undefined || args.workingLocationLabel !== undefined; const hasTimePatch = args.start !== undefined || args.end !== undefined || args.allDay !== undefined; const attendeesToAdd = normalizeAttendees(args.addAttendees); let attendees = normalizeAttendees(args.attendees); const hasPatch = hasAccountMove || args.title !== undefined || args.description !== undefined || args.location !== undefined || args.start !== undefined || args.end !== undefined || args.startTimeZone !== undefined || args.endTimeZone !== undefined || args.allDay !== undefined || args.transparency !== undefined || args.visibility !== undefined || args.colorId !== undefined || args.attachments !== undefined || args.status !== undefined || recurrence !== undefined || attendees !== undefined || attendeesToAdd !== undefined || Object.keys(reminderFields).length > 0 || args.addGoogleMeet === true || args.removeGoogleMeet === true || args.addZoom === true || hasWorkingLocationPatch; if (!hasPatch) { throw new Error("No event updates provided."); } const updates: Partial = { accountEmail, }; if (args.title !== undefined) updates.title = args.title; if (args.description !== undefined) updates.description = args.description; if (args.location !== undefined) updates.location = args.location; if (args.start !== undefined) updates.start = args.start; if (args.end !== undefined) updates.end = args.end; if (args.startTimeZone !== undefined) updates.startTimeZone = args.startTimeZone; if (args.endTimeZone !== undefined) updates.endTimeZone = args.endTimeZone; if (args.allDay !== undefined) updates.allDay = args.allDay; if (args.transparency !== undefined) updates.transparency = args.transparency; if (args.visibility !== undefined) updates.visibility = args.visibility; if (args.status !== undefined) updates.status = args.status; if (args.colorId !== undefined) updates.colorId = args.colorId; if (args.attachments !== undefined) updates.attachments = args.attachments; if (recurrence !== undefined) updates.recurrence = recurrence; if (attendees !== undefined) updates.attendees = attendees; Object.assign(updates, reminderFields); let existingEvent: CalendarEvent | undefined; const loadExistingEvent = async () => { existingEvent ??= await googleCalendar.getEvent(googleEventId, { ownerEmail, accountEmail, }); return existingEvent; }; if (hasAccountMove) { if ( targetAccountEmail!.trim().toLowerCase() === accountEmail.trim().toLowerCase() ) { throw new Error( "The destination calendar must be different from the current calendar.", ); } if (args.scope === "all") { throw new Error( "Move a recurring event occurrence with scope single; moving an entire recurring series is not supported.", ); } const existingEvent = await loadExistingEvent(); if (!isCalendarEventOrganizer(existingEvent)) { fail("Only the event organizer can move or reschedule this event."); } const hasOtherEventPatch = args.title !== undefined || args.description !== undefined || args.location !== undefined || args.start !== undefined || args.end !== undefined || args.startTimeZone !== undefined || args.endTimeZone !== undefined || args.allDay !== undefined || args.transparency !== undefined || args.visibility !== undefined || args.status !== undefined || args.remindersUseDefault !== undefined || args.reminders !== undefined || args.attachments !== undefined || args.colorId !== undefined || args.reminderMinutes !== undefined || args.reminderMethod !== undefined || args.addGoogleMeet !== undefined || args.removeGoogleMeet !== undefined || args.addZoom !== undefined || args.recurrence !== undefined || args.attendees !== undefined || args.addAttendees !== undefined || args.workingLocationType !== undefined || args.workingLocationLabel !== undefined; if (hasOtherEventPatch) { throw new Error( "Move the event separately from other event field changes.", ); } const sendUpdates = args.sendUpdates ?? (existingEvent.attendees?.some((attendee) => !attendee.self) ? "all" : "none"); const result = await googleCalendar.moveEvent(googleEventId, { sourceAccount: { ownerEmail, accountEmail }, destinationAccount: { ownerEmail, accountEmail: targetAccountEmail!, }, sendUpdates, }); if (!result.id) { throw new Error("Google did not return an id for the moved event."); } const guestNotification = guestNotificationMessage ? await sendEventGuestNotificationNote({ event: { ...existingEvent, id: `google-${result.id}`, googleEventId: result.id, accountEmail: targetAccountEmail, htmlLink: result.htmlLink, hangoutLink: result.meetLink, conferenceData: result.conferenceData, }, organizerEmail: ownerEmail, message: guestNotificationMessage, kind: "update", }) : undefined; track( "event_rescheduled", { app_name: "calendar", template_name: "calendar", event_id: `google-${result.id}`, output_id: `google-${result.id}`, output_type: "calendar_event", change_type: "account_move", }, actionContext, ); const id = googleEventResultId(args.id, result.id, targetAccountEmail!); return { success: true, id, replacedId: googleEventResultId(args.id, googleEventId, accountEmail), accountEmail: targetAccountEmail, updated: ["accountEmail"], htmlLink: result.htmlLink, hangoutLink: result.meetLink, conferenceData: result.conferenceData, change: eventChange(id, existingEvent.title), ...(guestNotification ? { guestNotification } : {}), }; } if (args.location !== undefined && !hasWorkingLocationPatch) { const existingEvent = await loadExistingEvent(); if (existingEvent.eventType === "workingLocation") { throw new Error( "Working-location events do not support a generic location. Use workingLocationType and workingLocationLabel instead.", ); } } if (hasTimePatch) { const existingEvent = await loadExistingEvent(); if (!isCalendarEventOrganizer(existingEvent)) { fail("Only the event organizer can move or reschedule this event."); } const existingStatusEventType = existingEvent.eventType === "outOfOffice" || existingEvent.eventType === "focusTime" || existingEvent.eventType === "workingLocation" ? existingEvent.eventType : "default"; validateStatusEventTiming({ eventType: existingStatusEventType, allDay: args.allDay ?? existingEvent.allDay, start: args.start ?? existingEvent.start, end: args.end ?? existingEvent.end, }); validateEventTimeOrder({ allDay: args.allDay ?? existingEvent.allDay, start: args.start ?? existingEvent.start, end: args.end ?? existingEvent.end, }); updates.allDay = args.allDay ?? existingEvent.allDay; if ( existingEvent.eventType === "workingLocation" && existingEvent.workingLocationProperties ) { Object.assign(updates, { eventType: "workingLocation" as const, transparency: "transparent" as const, visibility: "public" as const, workingLocationProperties: existingEvent.workingLocationProperties, }); } } if (hasWorkingLocationPatch) { const existingEvent = await loadExistingEvent(); if (existingEvent.eventType !== "workingLocation") { throw new Error( "Working location details can only be updated on existing working-location events. Google Calendar event types cannot be changed after creation.", ); } const nextWorkingLocationType = args.workingLocationType ?? existingEvent.workingLocationProperties?.type ?? "customLocation"; const existingWorkingLocationLabel = existingEvent.workingLocationProperties?.type === "officeLocation" ? existingEvent.workingLocationProperties.officeLocation?.label : existingEvent.workingLocationProperties?.type === "customLocation" ? existingEvent.workingLocationProperties.customLocation?.label : undefined; const nextWorkingLocationLabel = args.workingLocationLabel ?? args.location ?? existingWorkingLocationLabel ?? existingEvent.title; const workingLocationFields = buildStatusEventFields({ eventType: "workingLocation", title: args.title ?? existingEvent.title, workingLocationType: nextWorkingLocationType, workingLocationLabel: nextWorkingLocationLabel, }); const nextWorkingLocationProperties = nextWorkingLocationType === "officeLocation" ? { type: "officeLocation" as const, officeLocation: { ...(existingEvent.workingLocationProperties?.type === "officeLocation" ? existingEvent.workingLocationProperties.officeLocation : {}), label: nextWorkingLocationLabel, }, } : workingLocationFields.workingLocationProperties; Object.assign(updates, { transparency: "transparent", visibility: "public", workingLocationProperties: nextWorkingLocationProperties, }); delete updates.location; } if (attendeesToAdd !== undefined) { const existingEvent = await loadExistingEvent(); attendees = mergeAttendees(existingEvent.attendees, attendeesToAdd); updates.attendees = attendees; } let zoomMeetingLink: string | undefined; let zoomAlreadyPresent = false; if (args.addZoom) { const existingEvent = await loadExistingEvent(); const eventForZoom: CalendarEvent = { ...existingEvent, ...updates, title: updates.title ?? existingEvent.title, description: updates.description ?? existingEvent.description, location: updates.location ?? existingEvent.location, start: updates.start ?? existingEvent.start, end: updates.end ?? existingEvent.end, }; const zoom = await prepareZoomMeetingPatch(ownerEmail, eventForZoom); zoomMeetingLink = zoom.meetingLink; zoomAlreadyPresent = zoom.alreadyPresent; Object.assign(updates, zoom.patch); } const eventForNotification = guestNotificationMessage ? await loadExistingEvent() : undefined; const updatedKeys = Object.keys(updates).filter( (key) => key !== "accountEmail", ); if (updatedKeys.length === 0 && zoomAlreadyPresent) { return { success: true, id: googleEventResultId(args.id, googleEventId, accountEmail), accountEmail, updated: [], meetingLink: zoomMeetingLink, message: "Zoom link already present.", }; } let result: Awaited>; let returnedGoogleEventId = googleEventId; const replaceRecurringWorkingLocation = hasWorkingLocationPatch && (args.scope ?? "single") === "single" && existingEvent?.recurringEventId && updates.workingLocationProperties; if (replaceRecurringWorkingLocation) { const hasUnsupportedReplacementPatch = args.title !== undefined || args.description !== undefined || args.start !== undefined || args.end !== undefined || args.startTimeZone !== undefined || args.endTimeZone !== undefined || args.allDay !== undefined || args.status !== undefined || args.colorId !== undefined || args.attachments !== undefined || args.reminders !== undefined || args.reminderMinutes !== undefined || args.remindersUseDefault !== undefined || args.addGoogleMeet === true || args.addZoom === true || args.recurrence !== undefined || args.attendees !== undefined || args.addAttendees !== undefined || args.notificationMessage !== undefined; if (hasUnsupportedReplacementPatch) { throw new Error( "Change the working location separately from other event fields for a single recurring occurrence.", ); } const now = new Date().toISOString(); const replacement = await googleCalendar.createEvent( { id: "", title: workingLocationTitle(updates.workingLocationProperties!), description: "", start: existingEvent!.start, end: existingEvent!.end, startTimeZone: existingEvent!.startTimeZone, endTimeZone: existingEvent!.endTimeZone, location: "", allDay: existingEvent!.allDay, source: "google", accountEmail, colorId: existingEvent!.colorId, transparency: "transparent", visibility: "public", status: "confirmed", eventType: "workingLocation", workingLocationProperties: updates.workingLocationProperties, createdAt: now, updatedAt: now, }, { account: { ownerEmail, accountEmail } }, ); if (!replacement.id) { throw new Error( "Google did not return an id for the working location.", ); } try { await googleCalendar.deleteEvent( googleEventId, { ownerEmail, accountEmail }, { scope: "single" }, ); } catch (error) { try { await googleCalendar.deleteEvent( replacement.id, { ownerEmail, accountEmail }, { scope: "single" }, ); } catch (cleanupError) { console.error( "Failed to clean up a working-location replacement after the original occurrence could not be cancelled.", cleanupError, ); } throw error; } returnedGoogleEventId = replacement.id; result = { htmlLink: replacement.htmlLink, meetLink: replacement.meetLink, conferenceData: replacement.conferenceData, }; } else { result = await googleCalendar.updateEvent(googleEventId, updates, { account: { ownerEmail, accountEmail }, sendUpdates: args.sendUpdates ?? (guestNotificationMessage || (attendeesToAdd?.length ?? 0) > 0 ? "all" : undefined), addGoogleMeet: args.addGoogleMeet, removeGoogleMeet: args.removeGoogleMeet, scope: args.scope, }); } const returnedPatch: Partial = {}; if (result.htmlLink) returnedPatch.htmlLink = result.htmlLink; if (result.meetLink) returnedPatch.hangoutLink = result.meetLink; if (result.conferenceData) { returnedPatch.conferenceData = result.conferenceData; } if (result.attendees !== undefined) { returnedPatch.attendees = result.attendees; } else if (attendees !== undefined) { returnedPatch.attendees = attendees; } const guestNotification = guestNotificationMessage && eventForNotification ? await sendEventGuestNotificationNote({ event: { ...eventForNotification, ...updates, id: `google-${googleEventId}`, googleEventId, accountEmail, htmlLink: result.htmlLink, hangoutLink: result.meetLink, conferenceData: result.conferenceData, }, organizerEmail: ownerEmail, message: guestNotificationMessage, kind: "update", }) : undefined; if (hasTimePatch) { track( "event_rescheduled", { app_name: "calendar", template_name: "calendar", event_id: `google-${returnedGoogleEventId}`, output_id: `google-${returnedGoogleEventId}`, output_type: "calendar_event", change_type: "time", }, actionContext, ); } const id = googleEventResultId( args.id, returnedGoogleEventId, accountEmail, ); const title = hasWorkingLocationPatch && updates.workingLocationProperties ? workingLocationTitle(updates.workingLocationProperties) : (args.title ?? existingEvent?.title ?? "Event"); return { success: true, id, ...(returnedGoogleEventId !== googleEventId ? { replacedId: googleEventResultId( args.id, googleEventId, accountEmail, ), } : {}), accountEmail, updated: updatedKeys, htmlLink: result.htmlLink, hangoutLink: result.meetLink, meetingLink: zoomMeetingLink, conferenceData: result.conferenceData, ...(args.removeGoogleMeet ? { removedGoogleMeet: true } : {}), ...returnedPatch, change: eventChange(id, title), ...(guestNotification ? { guestNotification } : {}), }; }, });