1 | |
2 |
|
3 | |
4 | |
5 |
|
6 | import * as wmill from "windmill-client"; |
7 |
|
8 | |
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 | |
15 | type GoogleTriggerPayload = { |
16 | channel_id: string; |
17 | resource_id: string; |
18 | resource_state: string; |
19 | resource_uri: string; |
20 | message_number: string; |
21 | channel_expiration: string; |
22 | channel_token: string; |
23 | changed: string; |
24 | }; |
25 |
|
26 | |
27 | export async function main(payload: GoogleTriggerPayload) { |
28 | |
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 | |
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 | |
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 | |
84 | let startPageToken: string | undefined; |
85 | const state = await wmill.getState(); |
86 | if (state?.startPageToken) { |
87 | startPageToken = state.startPageToken; |
88 | } |
89 |
|
90 | |
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 | |
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 | |
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 | |
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 | |
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 | |
198 | |
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 | |
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 | |
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 |
|