import * as z from "zod" import { defineAction } from "../../../automation/actions" import { getGoogleCalendarApi, normalizeCalendarEvent, normalizeEventId, normalizeEventPage, resolveCalendarId, resolveCalendarIds, toEventResource, } from "@automate.ax/integration-contracts/google-calendar" import { GOOGLE_CALENDAR_REFERENCE_MOVE_WRITE_REQUIREMENT, GOOGLE_CALENDAR_REFERENCE_READ_REQUIREMENT, GOOGLE_CALENDAR_REFERENCE_WRITE_REQUIREMENT, } from "../lib/scopes" import { CALENDAR_EVENT_SCHEMA, CALENDAR_REFERENCE_SCHEMA, DATE_SCHEMA, EVENT_DETAILS_INPUT_SCHEMA, EVENT_PAGE_SCHEMA, EVENT_REFERENCE_SCHEMA, EVENT_SCHEDULE_INPUT_SCHEMA, EVENT_TYPE_SCHEMA, RFC3339_SCHEMA, SEND_UPDATES_SCHEMA, } from "@automate.ax/integration-contracts/google-calendar" const EVENT_LIST_INPUT_SCHEMA = z.object({ /** Exact calendar title or ID. Defaults to the primary calendar. */ calendar: CALENDAR_REFERENCE_SCHEMA.optional(), /** Event types to include. */ eventTypes: EVENT_TYPE_SCHEMA.array().min(1).optional(), /** Cross-calendar iCalendar UID to match. */ iCalUID: z.string().min(1).optional(), /** Include deleted events. */ includeDeleted: z.boolean().optional(), /** Include invitations hidden from the connected user's calendar. */ includeHiddenInvitations: z.boolean().optional(), /** Maximum number of attendees returned per event. */ maxAttendees: z.number().int().positive().optional(), /** Provider ordering for returned events. */ orderBy: z.enum(["startTime", "updated"]).optional(), /** Include private extended property in `name=value` form. */ privateExtendedProperty: z.string().min(1).array().optional(), /** Free-text search across event fields. */ query: z.string().min(1).optional(), /** Expand recurring series into individual instances. */ singleEvents: z.boolean().optional(), /** Include shared extended property in `name=value` form. */ sharedExtendedProperty: z.string().min(1).array().optional(), /** Incremental synchronization token from a previous completed listing. */ syncToken: z.string().min(1).optional(), /** Exclusive upper bound for event starts. */ timeMax: RFC3339_SCHEMA.optional(), /** Exclusive lower bound for event ends. */ timeMin: RFC3339_SCHEMA.optional(), /** IANA timezone used in the response. */ timeZone: z.string().min(1).optional(), /** Only events updated since this instant. */ updatedMin: RFC3339_SCHEMA.optional(), }) /** Event controls relevant to provider ordering validation. */ type EventOrderingInput = { orderBy?: "startTime" | "updated" singleEvents?: boolean } /** Event controls constrained during incremental synchronization. */ type EventSyncInput = { iCalUID?: string includeDeleted?: boolean orderBy?: "startTime" | "updated" privateExtendedProperty?: string[] query?: string sharedExtendedProperty?: string[] syncToken?: string timeMax?: string timeMin?: string updatedMin?: string } /** Event time bounds validated before provider requests. */ type EventTimeRangeInput = { timeMax?: string; timeMin?: string } /** Retrieves one Google Calendar event by ID or standard event URL. */ export const getGoogleCalendarEvent = defineAction("Get Google Calendar event") .describe("Gets a normalized event, including attendees and conferencing.") .account("google", GOOGLE_CALENDAR_REFERENCE_READ_REQUIREMENT) .input( z.object({ /** Exact calendar title or ID. Defaults to the primary calendar. */ calendar: CALENDAR_REFERENCE_SCHEMA.optional(), /** Immutable event ID or standard Google Calendar event URL. */ event: EVENT_REFERENCE_SCHEMA, /** Maximum number of attendees returned. */ maxAttendees: z.number().int().positive().optional(), /** IANA timezone used in the response. */ timeZone: z.string().min(1).optional(), }), ) .output(CALENDAR_EVENT_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const calendarApi = getGoogleCalendarApi(account.secret) const calendarId = await resolveCalendarId(calendarApi, input.calendar) const { data } = await calendarApi.events.get({ calendarId, eventId: normalizeEventId(input.event), maxAttendees: input.maxAttendees, timeZone: input.timeZone, }) return normalizeCalendarEvent(data, calendarId) }) /** * Lists one page of events while preserving provider pagination and sync * tokens. */ export const listGoogleCalendarEvents = defineAction( "List Google Calendar events", ) .describe("Lists a page of events with filters, pagination, and sync tokens.") .account("google", GOOGLE_CALENDAR_REFERENCE_READ_REQUIREMENT) .input( EVENT_LIST_INPUT_SCHEMA.extend({ /** Maximum events returned in this page. */ maxResults: z.number().int().min(1).max(2500).optional(), /** Pagination token returned by a previous request. */ pageToken: z.string().min(1).optional(), }) .refine( orderedEventsInputIsValid, 'orderBy: "startTime" requires singleEvents: true.', ) .refine( eventSyncInputIsValid, "syncToken cannot be combined with event filters or includeDeleted: false.", ) .refine(eventTimeRangeIsValid, "timeMax must follow timeMin."), ) .output(EVENT_PAGE_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const calendarApi = getGoogleCalendarApi(account.secret) const calendarId = await resolveCalendarId(calendarApi, input.calendar) const { data } = await calendarApi.events.list({ calendarId, eventTypes: input.eventTypes, iCalUID: input.iCalUID, maxAttendees: input.maxAttendees, maxResults: input.maxResults, orderBy: input.orderBy ?? (input.singleEvents && !input.syncToken ? "startTime" : undefined), pageToken: input.pageToken, privateExtendedProperty: input.privateExtendedProperty, q: input.query, sharedExtendedProperty: input.sharedExtendedProperty, showDeleted: input.includeDeleted, showHiddenInvitations: input.includeHiddenInvitations, singleEvents: input.singleEvents, syncToken: input.syncToken, timeMax: input.timeMax, timeMin: input.timeMin, timeZone: input.timeZone, updatedMin: input.updatedMin, }) return normalizeEventPage(data, calendarId) }) /** Searches events and internally follows pages up to a requested total limit. */ export const searchGoogleCalendarEvents = defineAction( "Search Google Calendar events", ) .describe("Searches event text and returns normalized matching events.") .account("google", GOOGLE_CALENDAR_REFERENCE_READ_REQUIREMENT) .input( EVENT_LIST_INPUT_SCHEMA.omit({ query: true, syncToken: true }) .extend({ /** Maximum total matches returned across internally fetched pages. */ limit: z.number().int().min(1).max(10_000).optional(), /** Free-text query across event fields. */ query: z.string().min(1), }) .refine( orderedEventsInputIsValid, 'orderBy: "startTime" requires singleEvents: true.', ) .refine(eventTimeRangeIsValid, "timeMax must follow timeMin."), ) .output(CALENDAR_EVENT_SCHEMA.array()) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const calendarApi = getGoogleCalendarApi(account.secret) const calendarId = await resolveCalendarId(calendarApi, input.calendar) const limit = input.limit ?? 100 const events: z.infer[] = [] let pageToken: string | undefined do { const { data } = await calendarApi.events.list({ calendarId, eventTypes: input.eventTypes, iCalUID: input.iCalUID, maxAttendees: input.maxAttendees, maxResults: Math.min(2500, limit - events.length), orderBy: input.orderBy ?? (input.singleEvents ? "startTime" : undefined), pageToken, privateExtendedProperty: input.privateExtendedProperty, q: input.query, sharedExtendedProperty: input.sharedExtendedProperty, showDeleted: input.includeDeleted, showHiddenInvitations: input.includeHiddenInvitations, singleEvents: input.singleEvents, timeMax: input.timeMax, timeMin: input.timeMin, timeZone: input.timeZone, updatedMin: input.updatedMin, }) events.push( ...(data.items ?? []).map((event) => normalizeCalendarEvent(event, calendarId), ), ) pageToken = data.nextPageToken ?? undefined } while (pageToken && events.length < limit) return events.slice(0, limit) }) /** Creates a timed or all-day event, optionally with a new Google Meet. */ export const createGoogleCalendarEvent = defineAction( "Create Google Calendar event", ) .describe("Creates an event with attendees, recurrence, and optional Meet.") .account("google", GOOGLE_CALENDAR_REFERENCE_WRITE_REQUIREMENT) .input( EVENT_DETAILS_INPUT_SCHEMA.extend({ /** Exact calendar title or ID. Defaults to the primary calendar. */ calendar: CALENDAR_REFERENCE_SCHEMA.optional(), /** Create and attach a new Google Meet conference. */ createMeet: z.boolean().optional(), /** Timed or all-day event schedule. */ schedule: EVENT_SCHEDULE_INPUT_SCHEMA, /** Guest notification behavior. */ sendUpdates: SEND_UPDATES_SCHEMA.optional(), /** Human-readable event title. */ summary: z.string().trim().min(1), }), ) .output(CALENDAR_EVENT_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const calendarApi = getGoogleCalendarApi(account.secret) const calendarId = await resolveCalendarId(calendarApi, input.calendar) const { data } = await calendarApi.events.insert({ calendarId, conferenceDataVersion: input.createMeet ? 1 : undefined, requestBody: { ...toEventResource(input), conferenceData: input.createMeet ? { createRequest: { conferenceSolutionKey: { type: "hangoutsMeet" }, requestId: crypto.randomUUID(), }, } : undefined, }, sendUpdates: input.sendUpdates, supportsAttachments: Boolean(input.attachments?.length), }) return normalizeCalendarEvent(data, calendarId) }) /** Patches selected fields on an existing event. */ export const updateGoogleCalendarEvent = defineAction( "Update Google Calendar event", ) .describe("Updates selected event fields without replacing the whole event.") .account("google", GOOGLE_CALENDAR_REFERENCE_WRITE_REQUIREMENT) .input( EVENT_DETAILS_INPUT_SCHEMA.extend({ /** Exact calendar title or ID. Defaults to the primary calendar. */ calendar: CALENDAR_REFERENCE_SCHEMA.optional(), /** Create and attach a new Google Meet conference. */ createMeet: z.boolean().optional(), /** Immutable event ID or standard Google Calendar event URL. */ event: EVENT_REFERENCE_SCHEMA, /** Guest notification behavior. */ sendUpdates: SEND_UPDATES_SCHEMA.optional(), /** Replacement event title. */ summary: z.string().trim().min(1).optional(), }).refine( (input) => [ input.attachments, input.attendees, input.colorId, input.createMeet === true ? true : undefined, input.description, input.extendedProperties, input.guestsCanInviteOthers, input.guestsCanModify, input.guestsCanSeeOtherGuests, input.location, input.recurrence, input.reminders, input.schedule, input.summary, input.transparency, input.visibility, ].some((value) => value !== undefined), "At least one event field must be updated.", ), ) .output(CALENDAR_EVENT_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const calendarApi = getGoogleCalendarApi(account.secret) const calendarId = await resolveCalendarId(calendarApi, input.calendar) const { data } = await calendarApi.events.patch({ calendarId, conferenceDataVersion: input.createMeet ? 1 : undefined, eventId: normalizeEventId(input.event), requestBody: { ...toEventResource(input), conferenceData: input.createMeet ? { createRequest: { conferenceSolutionKey: { type: "hangoutsMeet" }, requestId: crypto.randomUUID(), }, } : undefined, }, sendUpdates: input.sendUpdates, supportsAttachments: input.attachments !== undefined, }) return normalizeCalendarEvent(data, calendarId) }) /** Permanently deletes or cancels an event and returns its identity. */ export const deleteGoogleCalendarEvent = defineAction( "Delete Google Calendar event", ) .describe("Deletes an event from a calendar.") .account("google", GOOGLE_CALENDAR_REFERENCE_WRITE_REQUIREMENT) .input( z.object({ /** Exact calendar title or ID. Defaults to the primary calendar. */ calendar: CALENDAR_REFERENCE_SCHEMA.optional(), /** Immutable event ID or standard Google Calendar event URL. */ event: EVENT_REFERENCE_SCHEMA, /** Guest notification behavior. */ sendUpdates: SEND_UPDATES_SCHEMA.optional(), }), ) .output(z.object({ calendarId: z.string(), eventId: z.string() })) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const calendarApi = getGoogleCalendarApi(account.secret) const calendarId = await resolveCalendarId(calendarApi, input.calendar) const eventId = normalizeEventId(input.event) await calendarApi.events.delete({ calendarId, eventId, sendUpdates: input.sendUpdates, }) return { calendarId, eventId } }) /** Lists expanded instances of one recurring event series. */ export const listGoogleCalendarEventInstances = defineAction( "List Google Calendar event instances", ) .describe("Lists individual occurrences of a recurring event series.") .account("google", GOOGLE_CALENDAR_REFERENCE_READ_REQUIREMENT) .input( z .object({ /** Exact calendar title or ID. Defaults to the primary calendar. */ calendar: CALENDAR_REFERENCE_SCHEMA.optional(), /** Recurring parent event ID or standard event URL. */ event: EVENT_REFERENCE_SCHEMA, /** Maximum instances returned in this page. */ maxResults: z.number().int().min(1).max(2500).optional(), /** Pagination token returned by a previous request. */ pageToken: z.string().min(1).optional(), /** Include cancelled instances. */ includeDeleted: z.boolean().optional(), /** Maximum number of attendees returned per instance. */ maxAttendees: z.number().int().positive().optional(), /** Exclusive upper bound for instance starts. */ timeMax: RFC3339_SCHEMA.optional(), /** Original date or RFC 3339 start of the recurring instance. */ originalStart: z.union([DATE_SCHEMA, RFC3339_SCHEMA]).optional(), /** Inclusive lower bound for instance ends. */ timeMin: RFC3339_SCHEMA.optional(), /** IANA timezone used in the response. */ timeZone: z.string().min(1).optional(), }) .refine(eventTimeRangeIsValid, "timeMax must follow timeMin."), ) .output(EVENT_PAGE_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const calendarApi = getGoogleCalendarApi(account.secret) const calendarId = await resolveCalendarId(calendarApi, input.calendar) const { data } = await calendarApi.events.instances({ calendarId, eventId: normalizeEventId(input.event), maxAttendees: input.maxAttendees, maxResults: input.maxResults, originalStart: input.originalStart, pageToken: input.pageToken, showDeleted: input.includeDeleted, timeMax: input.timeMax, timeMin: input.timeMin, timeZone: input.timeZone, }) return normalizeEventPage(data, calendarId) }) /** Moves an organizer-owned event to another calendar. */ export const moveGoogleCalendarEvent = defineAction( "Move Google Calendar event", ) .describe("Moves an organizer-owned event between calendars.") .account("google", GOOGLE_CALENDAR_REFERENCE_MOVE_WRITE_REQUIREMENT) .input( z.object({ /** Exact destination calendar title or ID. */ destinationCalendar: CALENDAR_REFERENCE_SCHEMA, /** Immutable event ID or standard Google Calendar event URL. */ event: EVENT_REFERENCE_SCHEMA, /** Exact source calendar title or ID. Defaults to primary. */ sourceCalendar: CALENDAR_REFERENCE_SCHEMA.optional(), /** Guest notification behavior. */ sendUpdates: SEND_UPDATES_SCHEMA.optional(), }), ) .output(CALENDAR_EVENT_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const calendarApi = getGoogleCalendarApi(account.secret) const calendarIds = await resolveCalendarIds(calendarApi, [ input.sourceCalendar, input.destinationCalendar, ]) const destinationCalendarId = calendarIds[1]! const { data } = await calendarApi.events.move({ calendarId: calendarIds[0]!, destination: destinationCalendarId, eventId: normalizeEventId(input.event), sendUpdates: input.sendUpdates, }) return normalizeCalendarEvent(data, destinationCalendarId) }) /** Creates an event from a natural-language description. */ export const quickAddGoogleCalendarEvent = defineAction( "Quick-add Google Calendar event", ) .describe("Creates an event by parsing a natural-language description.") .account("google", GOOGLE_CALENDAR_REFERENCE_WRITE_REQUIREMENT) .input( z.object({ /** Exact calendar title or ID. Defaults to the primary calendar. */ calendar: CALENDAR_REFERENCE_SCHEMA.optional(), /** Natural-language event description including date and time. */ text: z.string().trim().min(1), /** Guest notification behavior. */ sendUpdates: SEND_UPDATES_SCHEMA.optional(), }), ) .output(CALENDAR_EVENT_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const calendarApi = getGoogleCalendarApi(account.secret) const calendarId = await resolveCalendarId(calendarApi, input.calendar) const { data } = await calendarApi.events.quickAdd({ calendarId, sendUpdates: input.sendUpdates, text: input.text, }) return normalizeCalendarEvent(data, calendarId) }) /** Imports an externally identified event without sending invitations. */ export const importGoogleCalendarEvent = defineAction( "Import Google Calendar event", ) .describe("Imports an event with an existing iCalendar UID for migration.") .account("google", GOOGLE_CALENDAR_REFERENCE_WRITE_REQUIREMENT) .input( EVENT_DETAILS_INPUT_SCHEMA.extend({ /** Exact calendar title or ID. Defaults to the primary calendar. */ calendar: CALENDAR_REFERENCE_SCHEMA.optional(), /** Existing cross-system iCalendar UID. */ iCalUID: z.string().min(1), /** Timed or all-day event schedule. */ schedule: EVENT_SCHEDULE_INPUT_SCHEMA, /** Human-readable event title. */ summary: z.string().trim().min(1), }), ) .output(CALENDAR_EVENT_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const calendarApi = getGoogleCalendarApi(account.secret) const calendarId = await resolveCalendarId(calendarApi, input.calendar) const { data } = await calendarApi.events.import({ calendarId, requestBody: { ...toEventResource(input), iCalUID: input.iCalUID, }, supportsAttachments: input.attachments !== undefined, }) return normalizeCalendarEvent(data, calendarId) }) /** Responds to an event invitation as the authenticated attendee. */ export const respondToGoogleCalendarInvitation = defineAction( "Respond to Google Calendar invitation", ) .describe("Accepts, declines, or tentatively accepts an event invitation.") .account("google", GOOGLE_CALENDAR_REFERENCE_WRITE_REQUIREMENT) .input( z.object({ /** Exact calendar title or ID containing the invitation. */ calendar: CALENDAR_REFERENCE_SCHEMA.optional(), /** Optional response comment. */ comment: z.string().optional(), /** Immutable event ID or standard Google Calendar event URL. */ event: EVENT_REFERENCE_SCHEMA, /** Invitation response. */ response: z.enum(["accepted", "declined", "tentative"]), /** Whether to notify the organizer and attendees. */ sendUpdates: SEND_UPDATES_SCHEMA.optional(), }), ) .output(CALENDAR_EVENT_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const calendarApi = getGoogleCalendarApi(account.secret) const calendarId = await resolveCalendarId(calendarApi, input.calendar) const eventId = normalizeEventId(input.event) const { data: event } = await calendarApi.events.get({ calendarId, eventId, }) const self = event.attendees?.find((attendee) => attendee.self) if (!self?.email) { throw new Error( "The authenticated user is not an attendee of this event.", ) } const { data } = await calendarApi.events.patch({ calendarId, eventId, requestBody: { attendees: [ { ...self, comment: input.comment, responseStatus: input.response, }, ], attendeesOmitted: true, }, sendUpdates: input.sendUpdates, }) return normalizeCalendarEvent(data, calendarId) }) /** * Checks whether provider ordering is compatible with recurring-event * expansion. * * @param input - Event listing controls. */ function orderedEventsInputIsValid(input: EventOrderingInput) { return input.orderBy !== "startTime" || input.singleEvents === true } /** * Checks whether filters are compatible with incremental event synchronization. * * @param input - Event listing controls. */ function eventSyncInputIsValid(input: EventSyncInput) { return ( !input.syncToken || (input.iCalUID === undefined && input.includeDeleted !== false && input.orderBy === undefined && input.privateExtendedProperty === undefined && input.query === undefined && input.sharedExtendedProperty === undefined && input.timeMax === undefined && input.timeMin === undefined && input.updatedMin === undefined) ) } /** * Checks chronological event range bounds. * * @param input - Optional event time bounds. */ function eventTimeRangeIsValid(input: EventTimeRangeInput) { return ( !input.timeMax || !input.timeMin || Math.floor(new Date(input.timeMax).getTime() / 1000) > Math.floor(new Date(input.timeMin).getTime() / 1000) ) }