/** * Google Calendar watcher provider — uses incremental sync for efficient change detection. * * On first poll, performs a full sync to capture the current syncToken as the watermark. * Subsequent polls use the syncToken with events.list to detect new/updated events. * Falls back to listing recent upcoming events if the syncToken has expired (410 Gone). */ import type { OAuthConnection } from "../../oauth/connection.js"; import { resolveOAuthConnection } from "../../oauth/connection-resolver.js"; import { getLogger } from "../../util/logger.js"; import type { FetchResult, WatcherItem, WatcherProvider, } from "../provider-types.js"; const GOOGLE_CALENDAR_BASE_URL = "https://www.googleapis.com/calendar/v3"; // --------------------------------------------------------------------------- // Local types & helpers used by the watcher provider // --------------------------------------------------------------------------- /** Event time - either a dateTime with timezone or a date for all-day events. */ interface EventDateTime { dateTime?: string; date?: string; timeZone?: string; } /** Calendar event attendee. */ interface EventAttendee { email: string; displayName?: string; responseStatus?: "needsAction" | "declined" | "tentative" | "accepted"; self?: boolean; organizer?: boolean; optional?: boolean; } /** Calendar event organizer. */ interface EventOrganizer { email?: string; displayName?: string; self?: boolean; } /** A single Google Calendar event. */ interface CalendarEvent { id: string; status?: "confirmed" | "tentative" | "cancelled"; summary?: string; description?: string; location?: string; start?: EventDateTime; end?: EventDateTime; attendees?: EventAttendee[]; organizer?: EventOrganizer; htmlLink?: string; created?: string; updated?: string; } /** Events list response. */ interface CalendarEventsListResponse { items?: CalendarEvent[]; nextPageToken?: string; nextSyncToken?: string; } class CalendarApiError extends Error { constructor( public readonly status: number, public readonly statusText: string, message: string, ) { super(message); this.name = "CalendarApiError"; } } /** List events from a calendar. */ async function listEvents( connection: OAuthConnection, calendarId = "primary", options?: { timeMin?: string; timeMax?: string; maxResults?: number; query?: string; singleEvents?: boolean; orderBy?: "startTime" | "updated"; pageToken?: string; syncToken?: string; }, ): Promise { const query: Record = {}; if (options?.timeMin) { query.timeMin = options.timeMin; } if (options?.timeMax) { query.timeMax = options.timeMax; } query.maxResults = String(options?.maxResults ?? 25); if (options?.query) { query.q = options.query; } // Default to expanding recurring events into instances const singleEvents = options?.singleEvents ?? true; query.singleEvents = String(singleEvents); if (singleEvents && options?.orderBy) { query.orderBy = options.orderBy; } else if (singleEvents) { query.orderBy = "startTime"; } if (options?.pageToken) { query.pageToken = options.pageToken; } if (options?.syncToken) { query.syncToken = options.syncToken; } const resp = await connection.request({ method: "GET", path: `/calendars/${encodeURIComponent(calendarId)}/events`, query, baseUrl: GOOGLE_CALENDAR_BASE_URL, headers: { "Content-Type": "application/json", }, }); if (resp.status < 200 || resp.status >= 300) { const bodyStr = typeof resp.body === "string" ? resp.body : JSON.stringify(resp.body ?? ""); throw new CalendarApiError( resp.status, "", `Calendar API ${resp.status}: ${bodyStr}`, ); } if (resp.status === 204 || resp.body === undefined) { return undefined as unknown as CalendarEventsListResponse; } return resp.body as CalendarEventsListResponse; } const log = getLogger("watcher:google-calendar"); /** The credential service — calendar shares OAuth tokens with Gmail. */ const CREDENTIAL_SERVICE = "google"; function eventToItem(event: CalendarEvent, eventType: string): WatcherItem { const start = event.start?.dateTime ?? event.start?.date ?? ""; const end = event.end?.dateTime ?? event.end?.date ?? ""; // Include updated timestamp in the dedup key so subsequent edits to the // same event aren't silently dropped by the watcher_id + external_id constraint. const version = event.updated ?? ""; return { externalId: version ? `${event.id}@${version}` : event.id, eventType, summary: `Calendar event: ${event.summary ?? "(no title)"} — ${start}`, payload: { id: event.id, summary: event.summary ?? "", start, end, location: event.location ?? "", // Neither capped nor fenced here. The engine bounds every string in this // payload before storing it (`capPayloadForStorage`) and fences the whole // rendered event block in one `` envelope before the // model sees it, so both jobs are done once for every provider rather // than per field here. Google's events.list reference documents no // ceiling on `description`, and `location` has none either, so the // engine's pass is the only bound on both. description: event.description ?? "", status: event.status ?? "confirmed", organizer: event.organizer?.email ?? "", attendees: event.attendees?.map((a) => ({ email: a.email, responseStatus: a.responseStatus, })) ?? [], htmlLink: event.htmlLink ?? "", }, timestamp: event.updated ? new Date(event.updated).getTime() : Date.now(), }; } interface SyncResponse { items?: CalendarEvent[]; nextPageToken?: string; nextSyncToken?: string; } /** * Perform an incremental sync using the stored syncToken. * Follows pagination (nextPageToken) until the final page returns nextSyncToken. * Returns all accumulated events and the final nextSyncToken. */ async function incrementalSync( connection: OAuthConnection, syncToken: string, ): Promise { let allItems: CalendarEvent[] = []; let pageToken: string | undefined; let nextSyncToken: string | undefined; do { // Match fetchInitialSyncToken's query shape. Google's sync guide asks that // allowed params stay consistent between initial and incremental requests. // The sync stream is intentionally kept COLLAPSED (no singleEvents): a // single change to a recurring series then yields one changed parent event // rather than one event per expanded instance, which would otherwise flood // the watcher/LLM. Instance expansion happens only in the bounded display // query (fallbackFetch), which pairs singleEvents with a timeMin window. const query: Record = { syncToken, maxResults: "250" }; if (pageToken) { query.pageToken = pageToken; } const resp = await connection.request({ method: "GET", path: "/calendars/primary/events", query, baseUrl: GOOGLE_CALENDAR_BASE_URL, }); if (resp.status < 200 || resp.status >= 300) { const bodyStr = typeof resp.body === "string" ? resp.body : JSON.stringify(resp.body ?? ""); if (resp.status === 410) { throw new SyncTokenExpiredError(bodyStr); } throw new CalendarApiError( resp.status, "", `Calendar Sync API ${resp.status}: ${bodyStr}`, ); } const page = resp.body as SyncResponse; if (page.items) { allItems = allItems.concat(page.items); } pageToken = page.nextPageToken; nextSyncToken = page.nextSyncToken; } while (pageToken); return { items: allItems, nextSyncToken }; } /** * Establish the initial syncToken (stored as the watermark). * * Sends a bare listing request (maxResults only) that does NOT carry timeMin * or other filter params — Google withholds nextSyncToken when the request is * filtered. The resulting syncToken encodes the current calendar state so * subsequent incrementalSync() calls detect changes without needing a time * window. * * singleEvents is deliberately omitted so the sync stream stays collapsed: a * change to a recurring series yields one changed parent event rather than one * event per expanded instance (which would flood the watcher, especially for * open-ended recurrences that have no expansion bound). Instances are expanded * only in the bounded display query (fallbackFetch), which pairs singleEvents * with a timeMin window. * * Google's sync guide says params must be "consistent" between initial and * incremental requests to avoid undefined behavior: incrementalSync() omits * timeMin (it's forbidden with syncToken) and sends the same consistent subset * (syncToken + maxResults). * * Returns no items; the watermark marks the current point so the first * incremental sync picks up only events that change afterward. */ async function fetchInitialSyncToken( connection: OAuthConnection, ): Promise { let pageToken: string | undefined; let syncToken: string | undefined; do { // Google withholds nextSyncToken on filtered requests — no timeMin. Also no // singleEvents: the token stream stays collapsed so a recurring-series edit // surfaces as one changed parent, not one event per expanded instance. const query: Record = { maxResults: "250", }; if (pageToken) { query.pageToken = pageToken; } const resp = await connection.request({ method: "GET", path: "/calendars/primary/events", query, baseUrl: GOOGLE_CALENDAR_BASE_URL, }); if (resp.status < 200 || resp.status >= 300) { const bodyStr = typeof resp.body === "string" ? resp.body : JSON.stringify(resp.body ?? ""); throw new CalendarApiError( resp.status, "", `Calendar API ${resp.status}: ${bodyStr}`, ); } const page = (resp.body ?? {}) as CalendarEventsListResponse; syncToken = page.nextSyncToken; pageToken = page.nextPageToken; } while (pageToken && !syncToken); return syncToken; } class SyncTokenExpiredError extends Error { constructor(message: string) { super(message); this.name = "SyncTokenExpiredError"; } } export const googleCalendarProvider: WatcherProvider = { id: "google-calendar", displayName: "Google Calendar", requiredCredentialService: CREDENTIAL_SERVICE, untrustedContentSource: "calendar", async getInitialWatermark(credentialService: string): Promise { const connection = await resolveOAuthConnection(credentialService); const syncToken = await fetchInitialSyncToken(connection); if (!syncToken) { throw new Error("Calendar API did not return a syncToken"); } return syncToken; }, async fetchNew( credentialService: string, watermark: string | null, _config: Record, _watcherKey: string, ): Promise { const connection = await resolveOAuthConnection(credentialService); if (!watermark) { // No watermark — establish the initial syncToken and return no items. const syncToken = await fetchInitialSyncToken(connection); return { items: [], watermark: syncToken ?? "" }; } try { const syncResp = await incrementalSync(connection, watermark); const newWatermark = syncResp.nextSyncToken ?? watermark; if (!syncResp.items || syncResp.items.length === 0) { return { items: [], watermark: newWatermark }; } // Convert events to watcher items, distinguishing new vs updated const items: WatcherItem[] = []; for (const event of syncResp.items) { if (event.status === "cancelled") { continue; } const eventType = event.created === event.updated ? "new_calendar_event" : "updated_calendar_event"; items.push(eventToItem(event, eventType)); } log.info( { count: items.length, watermark: newWatermark }, "Calendar: fetched event changes", ); return { items, watermark: newWatermark }; } catch (err) { if (err instanceof SyncTokenExpiredError) { log.warn("Calendar syncToken expired, falling back to recent events"); return fallbackFetch(connection); } throw err; } }, }; /** * Fallback when syncToken expires: list upcoming events from today. */ async function fallbackFetch( connection: OAuthConnection, ): Promise { const now = new Date().toISOString(); const result = await listEvents(connection, "primary", { timeMin: now, maxResults: 25, singleEvents: true, orderBy: "startTime", }); const items = (result.items ?? []).map((event) => eventToItem(event, "new_calendar_event"), ); // Re-establish a fresh syncToken for the next watermark via the same // paging-only request as initialization; a filtered request would withhold // nextSyncToken. const syncToken = await fetchInitialSyncToken(connection); return { items, watermark: syncToken ?? "" }; }