{"flow":{"id":75,"summary":"Google native trigger template flow","versions":[289],"created_by":"hugo989","created_at":"2026-02-16T10:12:37.106Z","votes":0,"approved":true,"apps":["gworkspace"],"value":{"modules":[{"id":"handle_google_trigger","value":{"lock":"{\n  \"dependencies\": {\n    \"windmill-client\": \"latest\"\n  }\n}\n//bun.lock\n{\n  \"lockfileVersion\": 1,\n  \"configVersion\": 1,\n  \"workspaces\": {\n    \"\": {\n      \"dependencies\": {\n        \"windmill-client\": \"latest\",\n      },\n    },\n  },\n  \"packages\": {\n    \"windmill-client\": [\"windmill-client@1.635.1\", \"\", {}, \"sha512-ik99tmt5uuQ96SxnjvCTe97DDf6ZGzTsXkZf3fmTp/eHBc90eDorxwkeJQZyeVJ1UaqhxQRdjRFeb3LlIO0FrA==\"],\n  }\n}\n","type":"rawscript","content":"//native\n\n// Handles Google push notification payloads (Drive & Calendar watch channels).\n// Dispatches to the right handler based on the resource_uri in the payload.\n\nimport * as wmill from \"windmill-client\";\n\nconst GOOGLE_DRIVE_API = \"https://www.googleapis.com/drive/v3\";\nconst GOOGLE_CALENDAR_API = \"https://www.googleapis.com/calendar/v3\";\n\n// Mirrors the headers sent by Google push notifications (body is always empty)\ntype GoogleTriggerPayload = {\n  channel_id: string;\n  resource_id: string;\n  resource_state: string; // \"sync\" | \"exists\" | \"not_exists\" | \"update\"\n  resource_uri: string;\n  message_number: string;\n  channel_expiration: string;\n  channel_token: string; // custom token set when creating the watch, for verification\n  changed: string; // Drive-only: comma-separated list (e.g. \"content,properties,permissions\")\n};\n\n// Routes the notification to the appropriate handler: Drive (whole), file-specific, or Calendar\nexport async function main(payload: GoogleTriggerPayload, gworkspace: RT.Gworkspace) {\n  // Google sends a \"sync\" event when a watch channel is first created — just acknowledge it\n  if (payload.resource_state === \"sync\") {\n    return {\n      status: \"sync\",\n      message: \"Initial sync notification acknowledged\",\n    };\n  }\n\n  const token = gworkspace.token;\n\n  const headers = {\n    Authorization: `Bearer ${token}`,\n    \"Content-Type\": \"application/json\",\n  };\n\n  // Detect the type of watch based on the resource URI\n  const resourceUri = payload.resource_uri;\n\n  if (resourceUri.includes(\"googleapis.com/calendar\")) {\n    return await handleCalendarChange(payload, headers);\n  } else if (\n    resourceUri.includes(\"googleapis.com/drive\") &&\n    resourceUri.includes(\"/files/\")\n  ) {\n    return await handleFileChange(payload, headers);\n  } else if (resourceUri.includes(\"googleapis.com/drive\")) {\n    return await handleDriveChange(payload, headers);\n  }\n\n  return {\n    status: \"unknown\",\n    message: `Unrecognized resource URI: ${resourceUri}`,\n    payload,\n  };\n}\n\n// Whole-drive watch: uses the Changes API with a persisted page token to fetch incremental changes\nasync function handleDriveChange(\n  payload: GoogleTriggerPayload,\n  headers: Record<string, string>,\n) {\n  const changedTypes = payload.changed\n    ? payload.changed.split(\",\").map((s) => s.trim())\n    : [];\n\n  const results: Record<string, any> = {\n    type: \"drive_watch\",\n    resource_state: payload.resource_state,\n    changed_types: changedTypes,\n    changes: [],\n  };\n\n  // Get the start page token from state, or initialize\n  let startPageToken: string | undefined;\n  const state = await wmill.getState();\n  if (state?.startPageToken) {\n    startPageToken = state.startPageToken;\n  }\n\n  // First run: no token yet, initialize and return early\n  if (!startPageToken) {\n    const tokenRes = await fetch(`${GOOGLE_DRIVE_API}/changes/startPageToken`, {\n      headers,\n    });\n    const tokenData = await tokenRes.json();\n    await wmill.setState({ startPageToken: tokenData.startPageToken });\n    results.message = \"Initialized change tracking, no previous token found\";\n    return results;\n  }\n\n  // Paginate through all changes since last run\n  let pageToken: string | undefined = startPageToken;\n  while (pageToken) {\n    const params = new URLSearchParams({\n      pageToken,\n      fields:\n        \"nextPageToken,newStartPageToken,changes(fileId,removed,time,file(id,name,mimeType,modifiedTime,trashed,webViewLink))\",\n      pageSize: \"100\",\n    });\n    const res = await fetch(`${GOOGLE_DRIVE_API}/changes?${params}`, {\n      headers,\n    });\n    const data = await res.json();\n\n    if (data.changes) {\n      results.changes.push(...data.changes);\n    }\n\n    if (data.newStartPageToken) {\n      await wmill.setState({ startPageToken: data.newStartPageToken });\n    }\n\n    pageToken = data.nextPageToken;\n  }\n\n  return results;\n}\n\n// Single-file watch: fetches the latest file metadata and recent revisions\nasync function handleFileChange(\n  payload: GoogleTriggerPayload,\n  headers: Record<string, string>,\n) {\n  const fileIdMatch = payload.resource_uri.match(/\\/files\\/([^?/]+)/);\n  const fileId = fileIdMatch?.[1];\n\n  if (!fileId) {\n    return {\n      type: \"file_watch\",\n      status: \"error\",\n      message: `Could not extract file ID from URI: ${payload.resource_uri}`,\n    };\n  }\n\n  const params = new URLSearchParams({\n    fields:\n      \"id,name,mimeType,modifiedTime,lastModifyingUser,size,trashed,version,webViewLink\",\n  });\n\n  const res = await fetch(`${GOOGLE_DRIVE_API}/files/${fileId}?${params}`, {\n    headers,\n  });\n  const file = await res.json();\n\n  let revisions: any[] = [];\n  try {\n    const revRes = await fetch(\n      `${GOOGLE_DRIVE_API}/files/${fileId}/revisions?fields=revisions(id,modifiedTime,lastModifyingUser,size)&pageSize=5`,\n      { headers },\n    );\n    const revData = await revRes.json();\n    revisions = revData.revisions ?? [];\n  } catch {\n    // Some file types don't support revisions\n  }\n\n  return {\n    type: \"file_watch\",\n    resource_state: payload.resource_state,\n    changed: payload.changed,\n    file,\n    recent_revisions: revisions,\n  };\n}\n\n// Calendar watch: uses incremental sync (syncToken) to fetch only changed events\nasync function handleCalendarChange(\n  payload: GoogleTriggerPayload,\n  headers: Record<string, string>,\n) {\n  const calendarIdMatch = payload.resource_uri.match(/\\/calendars\\/([^?/]+)/);\n  const calendarId = calendarIdMatch\n    ? decodeURIComponent(calendarIdMatch[1])\n    : \"primary\";\n\n  let syncToken: string | undefined;\n  const state = await wmill.getState();\n  const stateKey = `calendar_sync_${calendarId}`;\n  if (state?.[stateKey]) {\n    syncToken = state[stateKey];\n  }\n\n  const isBootstrap = !syncToken;\n\n  const params = new URLSearchParams({\n    singleEvents: \"true\",\n    // Bootstrap: max page size + minimal fields to grab the sync token fast\n    // Incremental: normal page size with full event data\n    maxResults: isBootstrap ? \"2500\" : \"50\",\n    ...(isBootstrap\n      ? { fields: \"nextPageToken,nextSyncToken,items(id)\" }\n      : {}),\n  });\n\n  if (syncToken) {\n    params.set(\"syncToken\", syncToken);\n  } else {\n    const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();\n    params.set(\"timeMin\", yesterday);\n  }\n\n  // Paginate through all pages to collect events and get the final nextSyncToken\n  const allEvents: any[] = [];\n  let syncReset = false;\n  let currentParams = params;\n\n  while (true) {\n    const res = await fetch(\n      `${GOOGLE_CALENDAR_API}/calendars/${encodeURIComponent(calendarId)}/events?${currentParams}`,\n      { headers },\n    );\n\n    // 410 GONE means the sync token expired — wipe it and do a full sync\n    if (res.status === 410) {\n      syncReset = true;\n      currentParams = new URLSearchParams({\n        singleEvents: \"true\",\n        maxResults: \"2500\",\n        fields: \"nextPageToken,nextSyncToken,items(id)\",\n        timeMin: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),\n      });\n      continue;\n    }\n\n    const data = await res.json();\n    if (!isBootstrap && !syncReset && data.items) {\n      allEvents.push(...data.items);\n    }\n\n    if (data.nextSyncToken) {\n      await wmill.setState({ ...state, [stateKey]: data.nextSyncToken });\n      break;\n    }\n\n    if (data.nextPageToken) {\n      currentParams = new URLSearchParams(params);\n      currentParams.set(\"pageToken\", data.nextPageToken);\n      currentParams.delete(\"syncToken\");\n      currentParams.delete(\"timeMin\");\n    } else {\n      break;\n    }\n  }\n\n  return {\n    type: \"calendar_watch\",\n    resource_state: payload.resource_state,\n    calendar_id: calendarId,\n    ...(isBootstrap && { bootstrap: true }),\n    ...(syncReset && { sync_reset: true }),\n    events: allEvents,\n  };\n}\n","language":"bunnative","input_transforms":{"payload":{"expr":"flow_input.payload","type":"javascript"},"gworkspace":{"type":"static"}}},"summary":"Process Google push notification payload"}]},"schema":{"type":"object","order":["payload"],"$schema":"https://json-schema.org/draft/2020-12/schema","required":["payload"],"properties":{"payload":{"type":"object","order":["channel_id","resource_id","resource_state","resource_uri","message_number","channel_expiration","channel_token","changed"],"required":["channel_id","resource_id","resource_state","resource_uri","message_number"],"properties":{"changed":{"type":"string","description":"Drive-only: comma-separated change types"},"channel_id":{"type":"string","description":"x-goog-channel-id"},"resource_id":{"type":"string","description":"x-goog-resource-id"},"resource_uri":{"type":"string","description":"API URI of the watched resource"},"channel_token":{"type":"string","description":"Custom verification token"},"message_number":{"type":"string","description":"Incrementing message counter"},"resource_state":{"type":"string","description":"sync | exists | not_exists | update"},"channel_expiration":{"type":"string","description":"Channel expiration time (if set)"}},"description":"Google push notification payload (from webhook headers)"}}},"description":"Single-step flow that processes Google watch webhook payloads. Routes to the appropriate handler (whole Drive, single file, or Calendar) and fetches the changed data.","recording":null,"vcreated_at":"2026-02-16T10:12:37.106Z","vcreated_by":"hugo989","comments":[]}}