runline 0.3.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,795 @@
1
+ /**
2
+ * Google Calendar plugin for runline.
3
+ *
4
+ * Authentication mirrors the gmail plugin: OAuth2 user flow, seeded
5
+ * once via `runline auth googleCalendar`. The connection stores
6
+ * `clientId`, `clientSecret`, `refreshToken`, plus cached
7
+ * `accessToken` + `accessTokenExpiresAt`. Token refresh is lazy —
8
+ * when the cached token is missing or within 60 s of expiry the
9
+ * plugin hits `https://oauth2.googleapis.com/token` and persists
10
+ * the new token via `ctx.updateConnection`.
11
+ *
12
+ * Surface area:
13
+ *
14
+ * calendar.list / calendar.get / calendar.availability (freeBusy)
15
+ * calendar.listColors
16
+ *
17
+ * event.create / event.get / event.list / event.update /
18
+ * event.delete / event.move / event.listInstances
19
+ *
20
+ * RRULE handling: callers can either supply a full `rrule` string
21
+ * (e.g. `FREQ=WEEKLY;INTERVAL=2;COUNT=5`) or the decomposed
22
+ * `repeatFrequency` / `repeatHowManyTimes` / `repeatUntil` fields,
23
+ * and the plugin assembles a single `RRULE:…` line. `event.get`
24
+ * and `event.list` attach a `nextOccurrence` field to recurring
25
+ * events, computed locally via the `rrule` package so we don't
26
+ * need a second API round-trip. Callers who want Google to expand
27
+ * the series server-side can pass `singleEvents=true` on list, or
28
+ * call `event.listInstances`.
29
+ */
30
+ import rrulePkg from "rrule";
31
+ // `rrule` ships as CJS; named imports fail under Node ESM.
32
+ const { RRule } = rrulePkg;
33
+ // ─── OAuth ───────────────────────────────────────────────────────
34
+ const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
35
+ const REFRESH_SKEW_MS = 60_000;
36
+ async function refreshAccessToken(ctx) {
37
+ const cfg = ctx.connection.config;
38
+ const { clientId, clientSecret, refreshToken } = cfg;
39
+ if (!clientId || !clientSecret || !refreshToken) {
40
+ throw new Error("googleCalendar: missing clientId/clientSecret/refreshToken. Run the Google Calendar OAuth helper to seed these.");
41
+ }
42
+ const body = new URLSearchParams({
43
+ client_id: clientId,
44
+ client_secret: clientSecret,
45
+ refresh_token: refreshToken,
46
+ grant_type: "refresh_token",
47
+ });
48
+ const res = await fetch(TOKEN_ENDPOINT, {
49
+ method: "POST",
50
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
51
+ body: body.toString(),
52
+ });
53
+ if (!res.ok) {
54
+ const text = await res.text();
55
+ throw new Error(`googleCalendar: token refresh failed (${res.status}): ${text}`);
56
+ }
57
+ const data = (await res.json());
58
+ const expiresAt = Date.now() + data.expires_in * 1000;
59
+ await ctx.updateConnection({
60
+ accessToken: data.access_token,
61
+ accessTokenExpiresAt: expiresAt,
62
+ });
63
+ return data.access_token;
64
+ }
65
+ async function accessToken(ctx) {
66
+ const cfg = ctx.connection.config;
67
+ if (cfg.accessToken &&
68
+ typeof cfg.accessTokenExpiresAt === "number" &&
69
+ Date.now() < cfg.accessTokenExpiresAt - REFRESH_SKEW_MS) {
70
+ return cfg.accessToken;
71
+ }
72
+ return refreshAccessToken(ctx);
73
+ }
74
+ // ─── Request ─────────────────────────────────────────────────────
75
+ const API_BASE = "https://www.googleapis.com/calendar/v3";
76
+ async function calRequest(ctx, method, path, body, qs) {
77
+ const token = await accessToken(ctx);
78
+ const url = new URL(`${API_BASE}${path}`);
79
+ if (qs) {
80
+ for (const [k, v] of Object.entries(qs)) {
81
+ if (v === undefined || v === null)
82
+ continue;
83
+ if (Array.isArray(v)) {
84
+ for (const entry of v)
85
+ url.searchParams.append(k, String(entry));
86
+ }
87
+ else {
88
+ url.searchParams.set(k, String(v));
89
+ }
90
+ }
91
+ }
92
+ const init = {
93
+ method,
94
+ headers: {
95
+ Authorization: `Bearer ${token}`,
96
+ Accept: "application/json",
97
+ },
98
+ };
99
+ if (body && Object.keys(body).length > 0) {
100
+ init.headers["Content-Type"] = "application/json";
101
+ init.body = JSON.stringify(body);
102
+ }
103
+ const res = await fetch(url.toString(), init);
104
+ if (res.status === 204)
105
+ return { success: true };
106
+ const text = await res.text();
107
+ if (!res.ok) {
108
+ // 403/429 deserve a retry with exponential backoff — Calendar
109
+ // hands these out freely when you hit per-user quota.
110
+ throw new Error(`googleCalendar: ${method} ${path} → ${res.status} ${text}`);
111
+ }
112
+ return text ? JSON.parse(text) : { success: true };
113
+ }
114
+ async function paginateAll(ctx, path, key, qs) {
115
+ const out = [];
116
+ const query = { ...qs, maxResults: 100 };
117
+ do {
118
+ const page = (await calRequest(ctx, "GET", path, undefined, query));
119
+ const items = page[key] ?? [];
120
+ out.push(...items);
121
+ query.pageToken = page.nextPageToken;
122
+ } while (query.pageToken);
123
+ return out;
124
+ }
125
+ // ─── Helpers ─────────────────────────────────────────────────────
126
+ function encodeCalendarId(id) {
127
+ // Calendar IDs are email-shaped. Decode-then-encode tolerates
128
+ // double-encoded input from upstream callers.
129
+ return encodeURIComponent(decodeURIComponent(id));
130
+ }
131
+ function splitAttendees(input) {
132
+ if (input === undefined || input === null)
133
+ return undefined;
134
+ const raw = Array.isArray(input)
135
+ ? input
136
+ : String(input).split(",");
137
+ const emails = raw
138
+ .flatMap((s) => s.split(","))
139
+ .map((s) => s.trim())
140
+ .filter((s) => s.length > 0);
141
+ if (emails.length === 0)
142
+ return undefined;
143
+ return emails.map((email) => ({ email }));
144
+ }
145
+ function buildRecurrence(p) {
146
+ if (typeof p.rrule === "string" && p.rrule.length > 0) {
147
+ const s = p.rrule.startsWith("RRULE:") ? p.rrule : `RRULE:${p.rrule}`;
148
+ return [s];
149
+ }
150
+ const parts = [];
151
+ if (p.repeatFrequency) {
152
+ parts.push(`FREQ=${String(p.repeatFrequency).toUpperCase()}`);
153
+ }
154
+ if (p.repeatHowManyTimes !== undefined && p.repeatUntil !== undefined) {
155
+ throw new Error("googleCalendar: set either repeatHowManyTimes or repeatUntil, not both");
156
+ }
157
+ if (p.repeatHowManyTimes !== undefined) {
158
+ parts.push(`COUNT=${p.repeatHowManyTimes}`);
159
+ }
160
+ if (p.repeatUntil) {
161
+ // Google wants UTC basic-format: YYYYMMDDTHHMMSSZ.
162
+ const d = new Date(String(p.repeatUntil));
163
+ if (Number.isNaN(d.getTime())) {
164
+ throw new Error(`googleCalendar: invalid repeatUntil "${p.repeatUntil}"`);
165
+ }
166
+ const iso = d.toISOString(); // 2026-01-02T03:04:05.000Z
167
+ const compact = iso.replace(/[-:]/g, "").replace(/\.\d{3}/, "");
168
+ parts.push(`UNTIL=${compact}`);
169
+ }
170
+ if (parts.length === 0)
171
+ return undefined;
172
+ return [`RRULE:${parts.join(";")}`];
173
+ }
174
+ function buildEventTimes(start, end, allDay, timeZone) {
175
+ if (allDay) {
176
+ const fmt = (v) => {
177
+ const d = new Date(String(v));
178
+ if (Number.isNaN(d.getTime()))
179
+ throw new Error(`googleCalendar: invalid date "${v}"`);
180
+ return d.toISOString().slice(0, 10);
181
+ };
182
+ return { start: { date: fmt(start) }, end: { date: fmt(end) } };
183
+ }
184
+ const toISO = (v) => {
185
+ const d = new Date(String(v));
186
+ if (Number.isNaN(d.getTime()))
187
+ throw new Error(`googleCalendar: invalid dateTime "${v}"`);
188
+ return d.toISOString();
189
+ };
190
+ return {
191
+ start: { dateTime: toISO(start), timeZone },
192
+ end: { dateTime: toISO(end), timeZone },
193
+ };
194
+ }
195
+ /**
196
+ * For each recurring event in `items`, compute the next occurrence
197
+ * after `now` by combining its own `start` (DTSTART) with the first
198
+ * `RRULE:` line in `recurrence`, and stash it as `nextOccurrence`.
199
+ *
200
+ * Rules:
201
+ * • skip events whose RRULE has already ended (UNTIL in the past);
202
+ * • preserve the original duration on the computed next instance;
203
+ * • swallow per-event parse errors (warn, continue).
204
+ */
205
+ function addNextOccurrence(items) {
206
+ const now = new Date();
207
+ for (const item of items) {
208
+ if (!item.recurrence)
209
+ continue;
210
+ const rule = item.recurrence.find((r) => r.toUpperCase().startsWith("RRULE"));
211
+ if (!rule)
212
+ continue;
213
+ try {
214
+ const startISO = item.start?.dateTime ?? item.start?.date;
215
+ const endISO = item.end?.dateTime ?? item.end?.date;
216
+ if (!startISO || !endISO)
217
+ continue;
218
+ const start = new Date(startISO);
219
+ const end = new Date(endISO);
220
+ const dtstart = `DTSTART:${start
221
+ .toISOString()
222
+ .replace(/[-:]/g, "")
223
+ .replace(/\.\d{3}/, "")}`;
224
+ const rrule = RRule.fromString(`${dtstart}\n${rule}`);
225
+ const until = rrule.options?.until;
226
+ if (until && until < now)
227
+ continue;
228
+ const nextStart = rrule.after(now, false);
229
+ if (!nextStart)
230
+ continue;
231
+ const duration = end.getTime() - start.getTime();
232
+ const nextEnd = new Date(nextStart.getTime() + duration);
233
+ item.nextOccurrence = {
234
+ start: { dateTime: nextStart.toISOString(), timeZone: item.start?.timeZone },
235
+ end: { dateTime: nextEnd.toISOString(), timeZone: item.end?.timeZone },
236
+ };
237
+ }
238
+ catch (err) {
239
+ console.warn(`googleCalendar: failed to resolve next occurrence for ${item.id}: ${err.message}`);
240
+ }
241
+ }
242
+ return items;
243
+ }
244
+ function uuid() {
245
+ // Good enough for conferenceData.createRequest.requestId — Google
246
+ // just wants a per-event unique string.
247
+ const g = globalThis;
248
+ if (g.crypto?.randomUUID)
249
+ return g.crypto.randomUUID();
250
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
251
+ }
252
+ function applyEventFields(body, p) {
253
+ if (typeof p.summary === "string")
254
+ body.summary = p.summary;
255
+ if (typeof p.description === "string")
256
+ body.description = p.description;
257
+ if (typeof p.location === "string")
258
+ body.location = p.location;
259
+ if (typeof p.colorId === "string")
260
+ body.colorId = p.colorId;
261
+ if (typeof p.id === "string")
262
+ body.id = p.id;
263
+ if (typeof p.transparency === "string")
264
+ body.transparency = p.transparency;
265
+ if (typeof p.visibility === "string")
266
+ body.visibility = p.visibility;
267
+ if (typeof p.guestsCanInviteOthers === "boolean")
268
+ body.guestsCanInviteOthers = p.guestsCanInviteOthers;
269
+ if (typeof p.guestsCanModify === "boolean")
270
+ body.guestsCanModify = p.guestsCanModify;
271
+ if (typeof p.guestsCanSeeOtherGuests === "boolean")
272
+ body.guestsCanSeeOtherGuests = p.guestsCanSeeOtherGuests;
273
+ const attendees = splitAttendees(p.attendees);
274
+ if (attendees)
275
+ body.attendees = attendees;
276
+ if (p.reminders !== undefined || p.useDefaultReminders !== undefined) {
277
+ const useDefault = p.useDefaultReminders !== false && !Array.isArray(p.reminders);
278
+ body.reminders = { useDefault };
279
+ if (Array.isArray(p.reminders)) {
280
+ body.reminders.overrides = p.reminders;
281
+ }
282
+ }
283
+ if (p.conferenceSolution) {
284
+ body.conferenceData = {
285
+ createRequest: {
286
+ requestId: uuid(),
287
+ conferenceSolutionKey: { type: String(p.conferenceSolution) },
288
+ },
289
+ };
290
+ }
291
+ }
292
+ // ─── Plugin ──────────────────────────────────────────────────────
293
+ const SCOPES = ["https://www.googleapis.com/auth/calendar"];
294
+ export default function googleCalendar(rl) {
295
+ rl.setName("googleCalendar");
296
+ rl.setVersion("0.1.0");
297
+ rl.setOAuth({
298
+ authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
299
+ tokenUrl: "https://oauth2.googleapis.com/token",
300
+ scopes: SCOPES,
301
+ authParams: { access_type: "offline", prompt: "consent" },
302
+ setupHelp: [
303
+ "You need a Google Cloud OAuth client. Takes ~5 minutes, one time.",
304
+ "",
305
+ "1. Create or pick a Google Cloud project:",
306
+ " https://console.cloud.google.com/projectcreate",
307
+ "",
308
+ "2. Enable the Google Calendar API:",
309
+ " https://console.cloud.google.com/apis/library/calendar-json.googleapis.com",
310
+ "",
311
+ "3. Configure the OAuth consent screen (first time only):",
312
+ " https://console.cloud.google.com/apis/credentials/consent",
313
+ " • Audience: External",
314
+ "",
315
+ "4. Add yourself as a test user:",
316
+ " https://console.cloud.google.com/auth/audience",
317
+ "",
318
+ "5. Create the OAuth client:",
319
+ " https://console.cloud.google.com/apis/credentials",
320
+ " • + Create credentials → OAuth client ID",
321
+ " • Application type: Web application",
322
+ " • Authorized redirect URIs → + Add URI: {{redirectUri}}",
323
+ "",
324
+ "6. Paste the Client ID and Client Secret below, or export",
325
+ " GOOGLE_CALENDAR_CLIENT_ID and GOOGLE_CALENDAR_CLIENT_SECRET.",
326
+ ],
327
+ });
328
+ rl.setConnectionSchema({
329
+ clientId: {
330
+ type: "string",
331
+ required: true,
332
+ description: "Google OAuth2 client ID",
333
+ env: "GOOGLE_CALENDAR_CLIENT_ID",
334
+ },
335
+ clientSecret: {
336
+ type: "string",
337
+ required: true,
338
+ description: "Google OAuth2 client secret",
339
+ env: "GOOGLE_CALENDAR_CLIENT_SECRET",
340
+ },
341
+ refreshToken: {
342
+ type: "string",
343
+ required: true,
344
+ description: "OAuth2 refresh token (obtained via login flow)",
345
+ env: "GOOGLE_CALENDAR_REFRESH_TOKEN",
346
+ },
347
+ accessToken: {
348
+ type: "string",
349
+ required: false,
350
+ description: "Cached access token (auto-refreshed)",
351
+ },
352
+ accessTokenExpiresAt: {
353
+ type: "number",
354
+ required: false,
355
+ description: "Cached access token expiry (ms since epoch)",
356
+ },
357
+ });
358
+ // ── Calendar ──────────────────────────────────────────
359
+ rl.registerAction("calendar.list", {
360
+ description: "List calendars the authenticated user has access to",
361
+ inputSchema: {
362
+ returnAll: { type: "boolean", required: false },
363
+ maxResults: { type: "number", required: false },
364
+ pageToken: { type: "string", required: false },
365
+ showHidden: { type: "boolean", required: false },
366
+ showDeleted: { type: "boolean", required: false },
367
+ minAccessRole: {
368
+ type: "string",
369
+ required: false,
370
+ description: "freeBusyReader | reader | writer | owner",
371
+ },
372
+ },
373
+ async execute(input, ctx) {
374
+ const p = (input ?? {});
375
+ const qs = {};
376
+ if (p.showHidden)
377
+ qs.showHidden = p.showHidden;
378
+ if (p.showDeleted)
379
+ qs.showDeleted = p.showDeleted;
380
+ if (p.minAccessRole)
381
+ qs.minAccessRole = p.minAccessRole;
382
+ if (p.pageToken)
383
+ qs.pageToken = p.pageToken;
384
+ if (p.returnAll)
385
+ return paginateAll(ctx, "/users/me/calendarList", "items", qs);
386
+ if (p.maxResults)
387
+ qs.maxResults = p.maxResults;
388
+ return calRequest(ctx, "GET", "/users/me/calendarList", undefined, qs);
389
+ },
390
+ });
391
+ rl.registerAction("calendar.get", {
392
+ description: "Get a calendar's metadata (including conference solutions)",
393
+ inputSchema: {
394
+ calendarId: {
395
+ type: "string",
396
+ required: true,
397
+ description: 'Calendar ID (email) or "primary"',
398
+ },
399
+ },
400
+ async execute(input, ctx) {
401
+ const { calendarId } = input;
402
+ return calRequest(ctx, "GET", `/users/me/calendarList/${encodeCalendarId(calendarId)}`);
403
+ },
404
+ });
405
+ rl.registerAction("calendar.availability", {
406
+ description: "Check free/busy information for one or more calendars over a time range",
407
+ inputSchema: {
408
+ calendarId: {
409
+ type: "string",
410
+ required: false,
411
+ description: 'Single calendar ID (email) or "primary"',
412
+ },
413
+ calendarIds: {
414
+ type: "array",
415
+ required: false,
416
+ description: "Multiple calendar IDs (takes precedence over calendarId)",
417
+ },
418
+ timeMin: {
419
+ type: "string",
420
+ required: true,
421
+ description: "ISO datetime — start of the interval",
422
+ },
423
+ timeMax: {
424
+ type: "string",
425
+ required: true,
426
+ description: "ISO datetime — end of the interval",
427
+ },
428
+ timeZone: { type: "string", required: false },
429
+ outputFormat: {
430
+ type: "string",
431
+ required: false,
432
+ description: "availability | bookedSlots | raw (default: raw)",
433
+ },
434
+ },
435
+ async execute(input, ctx) {
436
+ const p = (input ?? {});
437
+ const ids = Array.isArray(p.calendarIds)
438
+ ? p.calendarIds
439
+ : p.calendarId
440
+ ? [p.calendarId]
441
+ : [];
442
+ if (ids.length === 0) {
443
+ throw new Error("googleCalendar: calendarId or calendarIds is required");
444
+ }
445
+ const body = {
446
+ timeMin: new Date(String(p.timeMin)).toISOString(),
447
+ timeMax: new Date(String(p.timeMax)).toISOString(),
448
+ items: ids.map((id) => ({ id })),
449
+ };
450
+ if (p.timeZone)
451
+ body.timeZone = p.timeZone;
452
+ const res = (await calRequest(ctx, "POST", "/freeBusy", body));
453
+ const fmt = p.outputFormat ?? "raw";
454
+ if (fmt === "raw")
455
+ return res;
456
+ if (ids.length === 1) {
457
+ const entry = res.calendars?.[ids[0]];
458
+ if (entry?.errors) {
459
+ throw new Error(`googleCalendar: freeBusy error ${JSON.stringify(entry.errors)}`);
460
+ }
461
+ const busy = entry?.busy ?? [];
462
+ if (fmt === "availability")
463
+ return { available: busy.length === 0 };
464
+ if (fmt === "bookedSlots")
465
+ return busy;
466
+ }
467
+ // Multi-calendar: return a map per ID with the same shape as the single case.
468
+ const out = {};
469
+ for (const id of ids) {
470
+ const entry = res.calendars?.[id];
471
+ const busy = entry?.busy ?? [];
472
+ if (fmt === "availability")
473
+ out[id] = { available: busy.length === 0 };
474
+ else if (fmt === "bookedSlots")
475
+ out[id] = busy;
476
+ }
477
+ return out;
478
+ },
479
+ });
480
+ rl.registerAction("calendar.listColors", {
481
+ description: "List event and calendar color palettes available in Google Calendar",
482
+ async execute(_input, ctx) {
483
+ return calRequest(ctx, "GET", "/colors");
484
+ },
485
+ });
486
+ // ── Event ─────────────────────────────────────────────
487
+ rl.registerAction("event.create", {
488
+ description: "Create a calendar event",
489
+ inputSchema: {
490
+ calendarId: { type: "string", required: true },
491
+ summary: { type: "string", required: false },
492
+ description: { type: "string", required: false },
493
+ location: { type: "string", required: false },
494
+ start: {
495
+ type: "string",
496
+ required: true,
497
+ description: "ISO datetime (or YYYY-MM-DD when allDay)",
498
+ },
499
+ end: { type: "string", required: true },
500
+ allDay: { type: "boolean", required: false },
501
+ timeZone: { type: "string", required: false },
502
+ attendees: {
503
+ type: "array",
504
+ required: false,
505
+ description: "Array of email addresses, or a comma-separated string",
506
+ },
507
+ colorId: { type: "string", required: false },
508
+ id: { type: "string", required: false, description: "Custom event ID" },
509
+ transparency: {
510
+ type: "string",
511
+ required: false,
512
+ description: 'Show me as: "opaque" (busy) | "transparent" (free)',
513
+ },
514
+ visibility: { type: "string", required: false },
515
+ guestsCanInviteOthers: { type: "boolean", required: false },
516
+ guestsCanModify: { type: "boolean", required: false },
517
+ guestsCanSeeOtherGuests: { type: "boolean", required: false },
518
+ reminders: {
519
+ type: "array",
520
+ required: false,
521
+ description: '[{method: "email"|"popup", minutes: number}]',
522
+ },
523
+ useDefaultReminders: { type: "boolean", required: false },
524
+ rrule: { type: "string", required: false, description: "e.g. FREQ=WEEKLY;COUNT=5" },
525
+ repeatFrequency: {
526
+ type: "string",
527
+ required: false,
528
+ description: "daily | weekly | monthly | yearly",
529
+ },
530
+ repeatHowManyTimes: { type: "number", required: false },
531
+ repeatUntil: { type: "string", required: false, description: "ISO datetime" },
532
+ conferenceSolution: {
533
+ type: "string",
534
+ required: false,
535
+ description: "eventHangout | eventNamedHangout | hangoutsMeet",
536
+ },
537
+ sendUpdates: {
538
+ type: "string",
539
+ required: false,
540
+ description: "all | externalOnly | none",
541
+ },
542
+ maxAttendees: { type: "number", required: false },
543
+ },
544
+ async execute(input, ctx) {
545
+ const p = (input ?? {});
546
+ const body = {};
547
+ const times = buildEventTimes(p.start, p.end, p.allDay === true, p.timeZone);
548
+ body.start = times.start;
549
+ body.end = times.end;
550
+ applyEventFields(body, p);
551
+ const recurrence = buildRecurrence(p);
552
+ if (recurrence)
553
+ body.recurrence = recurrence;
554
+ const qs = {};
555
+ if (p.sendUpdates)
556
+ qs.sendUpdates = p.sendUpdates;
557
+ if (p.maxAttendees)
558
+ qs.maxAttendees = p.maxAttendees;
559
+ if (body.conferenceData)
560
+ qs.conferenceDataVersion = 1;
561
+ return calRequest(ctx, "POST", `/calendars/${encodeCalendarId(p.calendarId)}/events`, body, qs);
562
+ },
563
+ });
564
+ rl.registerAction("event.get", {
565
+ description: "Get a single event",
566
+ inputSchema: {
567
+ calendarId: { type: "string", required: true },
568
+ eventId: { type: "string", required: true },
569
+ timeZone: { type: "string", required: false },
570
+ maxAttendees: { type: "number", required: false },
571
+ nextOccurrence: {
572
+ type: "boolean",
573
+ required: false,
574
+ description: "Attach nextOccurrence for recurring events (default: true)",
575
+ },
576
+ },
577
+ async execute(input, ctx) {
578
+ const p = (input ?? {});
579
+ const qs = {};
580
+ if (p.timeZone)
581
+ qs.timeZone = p.timeZone;
582
+ if (p.maxAttendees)
583
+ qs.maxAttendees = p.maxAttendees;
584
+ const res = (await calRequest(ctx, "GET", `/calendars/${encodeCalendarId(p.calendarId)}/events/${p.eventId}`, undefined, qs));
585
+ if (p.nextOccurrence !== false)
586
+ addNextOccurrence([res]);
587
+ return res;
588
+ },
589
+ });
590
+ rl.registerAction("event.list", {
591
+ description: "List events in a calendar. Set `singleEvents=true` to expand recurring events into instances.",
592
+ inputSchema: {
593
+ calendarId: { type: "string", required: true },
594
+ q: { type: "string", required: false, description: "Free-text query" },
595
+ timeMin: { type: "string", required: false, description: "ISO datetime" },
596
+ timeMax: { type: "string", required: false },
597
+ updatedMin: { type: "string", required: false },
598
+ timeZone: { type: "string", required: false },
599
+ iCalUID: { type: "string", required: false },
600
+ orderBy: {
601
+ type: "string",
602
+ required: false,
603
+ description: "startTime (requires singleEvents=true) | updated",
604
+ },
605
+ singleEvents: { type: "boolean", required: false },
606
+ showDeleted: { type: "boolean", required: false },
607
+ showHiddenInvitations: { type: "boolean", required: false },
608
+ maxAttendees: { type: "number", required: false },
609
+ maxResults: { type: "number", required: false },
610
+ pageToken: { type: "string", required: false },
611
+ returnAll: { type: "boolean", required: false },
612
+ fields: { type: "string", required: false },
613
+ nextOccurrence: {
614
+ type: "boolean",
615
+ required: false,
616
+ description: "Attach nextOccurrence to recurring events (default: true; ignored when singleEvents=true)",
617
+ },
618
+ },
619
+ async execute(input, ctx) {
620
+ const p = (input ?? {});
621
+ const qs = {};
622
+ for (const k of [
623
+ "q",
624
+ "timeZone",
625
+ "iCalUID",
626
+ "orderBy",
627
+ "singleEvents",
628
+ "showDeleted",
629
+ "showHiddenInvitations",
630
+ "maxAttendees",
631
+ "pageToken",
632
+ "fields",
633
+ ]) {
634
+ if (p[k] !== undefined)
635
+ qs[k] = p[k];
636
+ }
637
+ if (p.timeMin)
638
+ qs.timeMin = new Date(String(p.timeMin)).toISOString();
639
+ if (p.timeMax)
640
+ qs.timeMax = new Date(String(p.timeMax)).toISOString();
641
+ if (p.updatedMin)
642
+ qs.updatedMin = new Date(String(p.updatedMin)).toISOString();
643
+ const path = `/calendars/${encodeCalendarId(p.calendarId)}/events`;
644
+ const attachNext = p.nextOccurrence !== false && p.singleEvents !== true;
645
+ if (p.returnAll) {
646
+ const items = (await paginateAll(ctx, path, "items", qs));
647
+ if (attachNext)
648
+ addNextOccurrence(items);
649
+ return items;
650
+ }
651
+ if (p.maxResults)
652
+ qs.maxResults = p.maxResults;
653
+ const res = (await calRequest(ctx, "GET", path, undefined, qs));
654
+ if (attachNext && Array.isArray(res.items))
655
+ addNextOccurrence(res.items);
656
+ return res;
657
+ },
658
+ });
659
+ rl.registerAction("event.listInstances", {
660
+ description: "List instances of a recurring event",
661
+ inputSchema: {
662
+ calendarId: { type: "string", required: true },
663
+ eventId: { type: "string", required: true },
664
+ timeMin: { type: "string", required: false },
665
+ timeMax: { type: "string", required: false },
666
+ timeZone: { type: "string", required: false },
667
+ showDeleted: { type: "boolean", required: false },
668
+ maxResults: { type: "number", required: false },
669
+ pageToken: { type: "string", required: false },
670
+ returnAll: { type: "boolean", required: false },
671
+ },
672
+ async execute(input, ctx) {
673
+ const p = (input ?? {});
674
+ const qs = {};
675
+ if (p.timeMin)
676
+ qs.timeMin = new Date(String(p.timeMin)).toISOString();
677
+ if (p.timeMax)
678
+ qs.timeMax = new Date(String(p.timeMax)).toISOString();
679
+ if (p.timeZone)
680
+ qs.timeZone = p.timeZone;
681
+ if (p.showDeleted)
682
+ qs.showDeleted = p.showDeleted;
683
+ if (p.pageToken)
684
+ qs.pageToken = p.pageToken;
685
+ const path = `/calendars/${encodeCalendarId(p.calendarId)}/events/${p.eventId}/instances`;
686
+ if (p.returnAll)
687
+ return paginateAll(ctx, path, "items", qs);
688
+ if (p.maxResults)
689
+ qs.maxResults = p.maxResults;
690
+ return calRequest(ctx, "GET", path, undefined, qs);
691
+ },
692
+ });
693
+ rl.registerAction("event.update", {
694
+ description: "Patch an event (only supplied fields are changed). Set modifyTarget='series' to edit the entire recurrence instead of a single instance.",
695
+ inputSchema: {
696
+ calendarId: { type: "string", required: true },
697
+ eventId: { type: "string", required: true },
698
+ modifyTarget: {
699
+ type: "string",
700
+ required: false,
701
+ description: "instance (default) | series",
702
+ },
703
+ summary: { type: "string", required: false },
704
+ description: { type: "string", required: false },
705
+ location: { type: "string", required: false },
706
+ start: { type: "string", required: false },
707
+ end: { type: "string", required: false },
708
+ allDay: { type: "boolean", required: false },
709
+ timeZone: { type: "string", required: false },
710
+ attendees: { type: "array", required: false },
711
+ colorId: { type: "string", required: false },
712
+ transparency: { type: "string", required: false },
713
+ visibility: { type: "string", required: false },
714
+ guestsCanInviteOthers: { type: "boolean", required: false },
715
+ guestsCanModify: { type: "boolean", required: false },
716
+ guestsCanSeeOtherGuests: { type: "boolean", required: false },
717
+ reminders: { type: "array", required: false },
718
+ useDefaultReminders: { type: "boolean", required: false },
719
+ rrule: { type: "string", required: false },
720
+ repeatFrequency: { type: "string", required: false },
721
+ repeatHowManyTimes: { type: "number", required: false },
722
+ repeatUntil: { type: "string", required: false },
723
+ sendUpdates: { type: "string", required: false },
724
+ maxAttendees: { type: "number", required: false },
725
+ sendNotifications: { type: "boolean", required: false },
726
+ },
727
+ async execute(input, ctx) {
728
+ const p = (input ?? {});
729
+ let eventId = p.eventId;
730
+ const calendarId = encodeCalendarId(p.calendarId);
731
+ // Series edit: resolve the instance's recurringEventId and patch that instead.
732
+ if (p.modifyTarget === "series") {
733
+ const instance = (await calRequest(ctx, "GET", `/calendars/${calendarId}/events/${eventId}`));
734
+ if (!instance.recurringEventId) {
735
+ throw new Error(`googleCalendar: event ${eventId} is not part of a recurrence series`);
736
+ }
737
+ eventId = instance.recurringEventId;
738
+ }
739
+ const body = {};
740
+ if (p.start !== undefined || p.end !== undefined) {
741
+ if (p.start === undefined || p.end === undefined) {
742
+ throw new Error("googleCalendar: start and end must be provided together on update");
743
+ }
744
+ const times = buildEventTimes(p.start, p.end, p.allDay === true, p.timeZone);
745
+ body.start = times.start;
746
+ body.end = times.end;
747
+ }
748
+ applyEventFields(body, p);
749
+ const recurrence = buildRecurrence(p);
750
+ if (recurrence)
751
+ body.recurrence = recurrence;
752
+ const qs = {};
753
+ if (p.sendUpdates)
754
+ qs.sendUpdates = p.sendUpdates;
755
+ if (p.sendNotifications !== undefined)
756
+ qs.sendNotifications = p.sendNotifications;
757
+ if (p.maxAttendees)
758
+ qs.maxAttendees = p.maxAttendees;
759
+ return calRequest(ctx, "PATCH", `/calendars/${calendarId}/events/${eventId}`, body, qs);
760
+ },
761
+ });
762
+ rl.registerAction("event.delete", {
763
+ description: "Delete an event",
764
+ inputSchema: {
765
+ calendarId: { type: "string", required: true },
766
+ eventId: { type: "string", required: true },
767
+ sendUpdates: { type: "string", required: false },
768
+ },
769
+ async execute(input, ctx) {
770
+ const p = (input ?? {});
771
+ const qs = {};
772
+ if (p.sendUpdates)
773
+ qs.sendUpdates = p.sendUpdates;
774
+ return calRequest(ctx, "DELETE", `/calendars/${encodeCalendarId(p.calendarId)}/events/${p.eventId}`, undefined, qs);
775
+ },
776
+ });
777
+ rl.registerAction("event.move", {
778
+ description: "Move an event from one calendar to another",
779
+ inputSchema: {
780
+ calendarId: { type: "string", required: true, description: "Source calendar" },
781
+ eventId: { type: "string", required: true },
782
+ destinationCalendarId: { type: "string", required: true },
783
+ sendUpdates: { type: "string", required: false },
784
+ },
785
+ async execute(input, ctx) {
786
+ const p = (input ?? {});
787
+ const qs = {
788
+ destination: p.destinationCalendarId,
789
+ };
790
+ if (p.sendUpdates)
791
+ qs.sendUpdates = p.sendUpdates;
792
+ return calRequest(ctx, "POST", `/calendars/${encodeCalendarId(p.calendarId)}/events/${p.eventId}/move`, undefined, qs);
793
+ },
794
+ });
795
+ }