Google native trigger template script
One script reply has been approved by the moderators Verified

Handles Google push notification payloads (Drive & Calendar watch channels)

Created by hugo989 1 day ago
Submitted by hugo989 Typescript (fetch-only)
Verified 1 day ago
1
//native
2

3
// Handles Google push notification payloads (Drive & Calendar watch channels).
4
// Dispatches to the right handler based on the resource_uri in the payload.
5

6
import * as wmill from "windmill-client";
7

8
// Change this to point to your Google Workspace OAuth resource
9
const GWORKSPACE_RESOURCE_PATH = "<path/to/resource>";
10

11
const GOOGLE_DRIVE_API = "https://www.googleapis.com/drive/v3";
12
const GOOGLE_CALENDAR_API = "https://www.googleapis.com/calendar/v3";
13

14
// Mirrors the headers sent by Google push notifications (body is always empty)
15
type GoogleTriggerPayload = {
16
  channel_id: string;
17
  resource_id: string;
18
  resource_state: string; // "sync" | "exists" | "not_exists" | "update"
19
  resource_uri: string;
20
  message_number: string;
21
  channel_expiration: string;
22
  channel_token: string; // custom token set when creating the watch, for verification
23
  changed: string; // Drive-only: comma-separated list (e.g. "content,properties,permissions")
24
};
25

26
// Routes the notification to the appropriate handler: Drive (whole), file-specific, or Calendar
27
export async function main(payload: GoogleTriggerPayload) {
28
  // Google sends a "sync" event when a watch channel is first created — just acknowledge it
29
  if (payload.resource_state === "sync") {
30
    return {
31
      status: "sync",
32
      message: "Initial sync notification acknowledged",
33
    };
34
  }
35

36
  const gworkspace: RT.Gworkspace = await wmill.getResource(
37
    GWORKSPACE_RESOURCE_PATH,
38
  );
39
  const token = gworkspace.token;
40

41
  const headers = {
42
    Authorization: `Bearer ${token}`,
43
    "Content-Type": "application/json",
44
  };
45

46
  // Detect the type of watch based on the resource URI
47
  const resourceUri = payload.resource_uri;
48

49
  if (resourceUri.includes("googleapis.com/calendar")) {
50
    return await handleCalendarChange(payload, headers);
51
  } else if (
52
    resourceUri.includes("googleapis.com/drive") &&
53
    resourceUri.includes("/files/")
54
  ) {
55
    return await handleFileChange(payload, headers);
56
  } else if (resourceUri.includes("googleapis.com/drive")) {
57
    return await handleDriveChange(payload, headers);
58
  }
59

60
  return {
61
    status: "unknown",
62
    message: `Unrecognized resource URI: ${resourceUri}`,
63
    payload,
64
  };
65
}
66

67
// Whole-drive watch: uses the Changes API with a persisted page token to fetch incremental changes
68
async function handleDriveChange(
69
  payload: GoogleTriggerPayload,
70
  headers: Record<string, string>,
71
) {
72
  const changedTypes = payload.changed
73
    ? payload.changed.split(",").map((s) => s.trim())
74
    : [];
75

76
  const results: Record<string, any> = {
77
    type: "drive_watch",
78
    resource_state: payload.resource_state,
79
    changed_types: changedTypes,
80
    changes: [],
81
  };
82

83
  // Get the start page token from state, or initialize
84
  let startPageToken: string | undefined;
85
  const state = await wmill.getState();
86
  if (state?.startPageToken) {
87
    startPageToken = state.startPageToken;
88
  }
89

90
  // First run: no token yet, initialize and return early
91
  if (!startPageToken) {
92
    const tokenRes = await fetch(`${GOOGLE_DRIVE_API}/changes/startPageToken`, {
93
      headers,
94
    });
95
    const tokenData = await tokenRes.json();
96
    await wmill.setState({ startPageToken: tokenData.startPageToken });
97
    results.message = "Initialized change tracking, no previous token found";
98
    return results;
99
  }
100

101
  // Paginate through all changes since last run
102
  let pageToken: string | undefined = startPageToken;
103
  while (pageToken) {
104
    const params = new URLSearchParams({
105
      pageToken,
106
      fields:
107
        "nextPageToken,newStartPageToken,changes(fileId,removed,time,file(id,name,mimeType,modifiedTime,trashed,webViewLink))",
108
      pageSize: "100",
109
    });
110
    const res = await fetch(`${GOOGLE_DRIVE_API}/changes?${params}`, {
111
      headers,
112
    });
113
    const data = await res.json();
114

115
    if (data.changes) {
116
      results.changes.push(...data.changes);
117
    }
118

119
    if (data.newStartPageToken) {
120
      await wmill.setState({ startPageToken: data.newStartPageToken });
121
    }
122

123
    pageToken = data.nextPageToken;
124
  }
125

126
  return results;
127
}
128

129
// Single-file watch: fetches the latest file metadata and recent revisions
130
async function handleFileChange(
131
  payload: GoogleTriggerPayload,
132
  headers: Record<string, string>,
133
) {
134
  const fileIdMatch = payload.resource_uri.match(/\/files\/([^?/]+)/);
135
  const fileId = fileIdMatch?.[1];
136

137
  if (!fileId) {
138
    return {
139
      type: "file_watch",
140
      status: "error",
141
      message: `Could not extract file ID from URI: ${payload.resource_uri}`,
142
    };
143
  }
144

145
  const params = new URLSearchParams({
146
    fields:
147
      "id,name,mimeType,modifiedTime,lastModifyingUser,size,trashed,version,webViewLink",
148
  });
149

150
  const res = await fetch(`${GOOGLE_DRIVE_API}/files/${fileId}?${params}`, {
151
    headers,
152
  });
153
  const file = await res.json();
154

155
  let revisions: any[] = [];
156
  try {
157
    const revRes = await fetch(
158
      `${GOOGLE_DRIVE_API}/files/${fileId}/revisions?fields=revisions(id,modifiedTime,lastModifyingUser,size)&pageSize=5`,
159
      { headers },
160
    );
161
    const revData = await revRes.json();
162
    revisions = revData.revisions ?? [];
163
  } catch {
164
    // Some file types don't support revisions
165
  }
166

167
  return {
168
    type: "file_watch",
169
    resource_state: payload.resource_state,
170
    changed: payload.changed,
171
    file,
172
    recent_revisions: revisions,
173
  };
174
}
175

176
// Calendar watch: uses incremental sync (syncToken) to fetch only changed events
177
async function handleCalendarChange(
178
  payload: GoogleTriggerPayload,
179
  headers: Record<string, string>,
180
) {
181
  const calendarIdMatch = payload.resource_uri.match(/\/calendars\/([^?/]+)/);
182
  const calendarId = calendarIdMatch
183
    ? decodeURIComponent(calendarIdMatch[1])
184
    : "primary";
185

186
  let syncToken: string | undefined;
187
  const state = await wmill.getState();
188
  const stateKey = `calendar_sync_${calendarId}`;
189
  if (state?.[stateKey]) {
190
    syncToken = state[stateKey];
191
  }
192

193
  const isBootstrap = !syncToken;
194

195
  const params = new URLSearchParams({
196
    singleEvents: "true",
197
    // Bootstrap: max page size + minimal fields to grab the sync token fast
198
    // Incremental: normal page size with full event data
199
    maxResults: isBootstrap ? "2500" : "50",
200
    ...(isBootstrap
201
      ? { fields: "nextPageToken,nextSyncToken,items(id)" }
202
      : {}),
203
  });
204

205
  if (syncToken) {
206
    params.set("syncToken", syncToken);
207
  } else {
208
    const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
209
    params.set("timeMin", yesterday);
210
  }
211

212
  // Paginate through all pages to collect events and get the final nextSyncToken
213
  const allEvents: any[] = [];
214
  let syncReset = false;
215
  let currentParams = params;
216

217
  while (true) {
218
    const res = await fetch(
219
      `${GOOGLE_CALENDAR_API}/calendars/${encodeURIComponent(calendarId)}/events?${currentParams}`,
220
      { headers },
221
    );
222

223
    // 410 GONE means the sync token expired — wipe it and do a full sync
224
    if (res.status === 410) {
225
      syncReset = true;
226
      currentParams = new URLSearchParams({
227
        singleEvents: "true",
228
        maxResults: "2500",
229
        fields: "nextPageToken,nextSyncToken,items(id)",
230
        timeMin: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
231
      });
232
      continue;
233
    }
234

235
    const data = await res.json();
236
    if (!isBootstrap && !syncReset && data.items) {
237
      allEvents.push(...data.items);
238
    }
239

240
    if (data.nextSyncToken) {
241
      await wmill.setState({ ...state, [stateKey]: data.nextSyncToken });
242
      break;
243
    }
244

245
    if (data.nextPageToken) {
246
      currentParams = new URLSearchParams(params);
247
      currentParams.set("pageToken", data.nextPageToken);
248
      currentParams.delete("syncToken");
249
      currentParams.delete("timeMin");
250
    } else {
251
      break;
252
    }
253
  }
254

255
  return {
256
    type: "calendar_watch",
257
    resource_state: payload.resource_state,
258
    calendar_id: calendarId,
259
    ...(isBootstrap && { bootstrap: true }),
260
    ...(syncReset && { sync_reset: true }),
261
    events: allEvents,
262
  };
263
}
264