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,691 @@
1
+ /**
2
+ * Google Contacts (People API) plugin for runline.
3
+ *
4
+ * OAuth2 user flow, same shape as the rest of the Google plugins.
5
+ * Scope: `auth/contacts` (full read/write on user's contacts).
6
+ *
7
+ * Surface area:
8
+ *
9
+ * contact.create / contact.get / contact.update / contact.delete
10
+ * contact.list (me/connections or search via people:searchContacts)
11
+ *
12
+ * group.list / group.get / group.create / group.update / group.delete
13
+ *
14
+ * Birthdays and events accept either a plain ISO date string
15
+ * (`"1990-03-14"`, `"2024-12-25T10:00:00Z"`) or Google's structured
16
+ * `{year, month, day}` object. Phone numbers, emails, addresses,
17
+ * organizations, relations, user-defined fields, and memberships
18
+ * pass through as arrays of Google's People-API entries.
19
+ *
20
+ * The People API uses `resourceName` ("people/c1234…") as the
21
+ * canonical identifier; we accept either the full `resourceName` or
22
+ * the bare ID and normalize. The response always includes a
23
+ * convenience `contactId` field stripped from `resourceName`.
24
+ */
25
+ // ─── Fields ─────────────────────────────────────────────────────
26
+ /**
27
+ * Every `personFields` value supported by People API get/list calls.
28
+ * Callers pass `fields: "*"` to request them all, or a specific
29
+ * subset as either a string or array.
30
+ */
31
+ const ALL_PERSON_FIELDS = [
32
+ "addresses",
33
+ "biographies",
34
+ "birthdays",
35
+ "coverPhotos",
36
+ "emailAddresses",
37
+ "events",
38
+ "genders",
39
+ "imClients",
40
+ "interests",
41
+ "locales",
42
+ "memberships",
43
+ "metadata",
44
+ "names",
45
+ "nicknames",
46
+ "occupations",
47
+ "organizations",
48
+ "phoneNumbers",
49
+ "photos",
50
+ "relations",
51
+ "residences",
52
+ "sipAddresses",
53
+ "skills",
54
+ "urls",
55
+ "userDefined",
56
+ ];
57
+ /**
58
+ * Fields that People API accepts on PATCH `updatePersonFields`.
59
+ * Matches the request schema — `metadata`, `photos`, `coverPhotos`,
60
+ * `locales`, `genders`, and `nicknames` aren't writable here.
61
+ */
62
+ const UPDATABLE_PERSON_FIELDS = new Set([
63
+ "addresses",
64
+ "biographies",
65
+ "birthdays",
66
+ "emailAddresses",
67
+ "events",
68
+ "imClients",
69
+ "interests",
70
+ "memberships",
71
+ "names",
72
+ "occupations",
73
+ "organizations",
74
+ "phoneNumbers",
75
+ "relations",
76
+ "sipAddresses",
77
+ "skills",
78
+ "urls",
79
+ "userDefined",
80
+ ]);
81
+ // ─── OAuth ───────────────────────────────────────────────────────
82
+ const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
83
+ const REFRESH_SKEW_MS = 60_000;
84
+ async function refreshAccessToken(ctx) {
85
+ const cfg = ctx.connection.config;
86
+ const { clientId, clientSecret, refreshToken } = cfg;
87
+ if (!clientId || !clientSecret || !refreshToken) {
88
+ throw new Error("googleContacts: missing clientId/clientSecret/refreshToken. Run the Contacts OAuth helper to seed these.");
89
+ }
90
+ const body = new URLSearchParams({
91
+ client_id: clientId,
92
+ client_secret: clientSecret,
93
+ refresh_token: refreshToken,
94
+ grant_type: "refresh_token",
95
+ });
96
+ const res = await fetch(TOKEN_ENDPOINT, {
97
+ method: "POST",
98
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
99
+ body: body.toString(),
100
+ });
101
+ if (!res.ok) {
102
+ throw new Error(`googleContacts: token refresh failed (${res.status}): ${await res.text()}`);
103
+ }
104
+ const data = (await res.json());
105
+ const expiresAt = Date.now() + data.expires_in * 1000;
106
+ await ctx.updateConnection({
107
+ accessToken: data.access_token,
108
+ accessTokenExpiresAt: expiresAt,
109
+ });
110
+ return data.access_token;
111
+ }
112
+ async function accessToken(ctx) {
113
+ const cfg = ctx.connection.config;
114
+ if (cfg.accessToken &&
115
+ typeof cfg.accessTokenExpiresAt === "number" &&
116
+ Date.now() < cfg.accessTokenExpiresAt - REFRESH_SKEW_MS) {
117
+ return cfg.accessToken;
118
+ }
119
+ return refreshAccessToken(ctx);
120
+ }
121
+ // ─── Request ─────────────────────────────────────────────────────
122
+ const API_BASE = "https://people.googleapis.com/v1";
123
+ async function peopleRequest(ctx, method, path, body, qs) {
124
+ const token = await accessToken(ctx);
125
+ const url = new URL(`${API_BASE}${path}`);
126
+ if (qs) {
127
+ for (const [k, v] of Object.entries(qs)) {
128
+ if (v === undefined || v === null)
129
+ continue;
130
+ if (Array.isArray(v)) {
131
+ for (const entry of v)
132
+ url.searchParams.append(k, String(entry));
133
+ }
134
+ else {
135
+ url.searchParams.set(k, String(v));
136
+ }
137
+ }
138
+ }
139
+ const init = {
140
+ method,
141
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
142
+ };
143
+ if (body && Object.keys(body).length > 0) {
144
+ init.headers["Content-Type"] = "application/json";
145
+ init.body = JSON.stringify(body);
146
+ }
147
+ const res = await fetch(url.toString(), init);
148
+ if (res.status === 204)
149
+ return { success: true };
150
+ const text = await res.text();
151
+ if (!res.ok) {
152
+ throw new Error(`googleContacts: ${method} ${path} → ${res.status} ${text}`);
153
+ }
154
+ return text ? JSON.parse(text) : { success: true };
155
+ }
156
+ async function paginateAll(ctx, path, key, qs) {
157
+ const out = [];
158
+ const query = { ...qs, pageSize: qs.pageSize ?? 100 };
159
+ do {
160
+ const page = (await peopleRequest(ctx, "GET", path, undefined, query));
161
+ const items = page[key] ?? [];
162
+ out.push(...items);
163
+ query.pageToken = page.nextPageToken;
164
+ } while (query.pageToken);
165
+ return out;
166
+ }
167
+ // ─── Helpers ────────────────────────────────────────────────────
168
+ /**
169
+ * Normalize a contact identifier into a `people/<id>` resourceName.
170
+ * Accepts either the raw ID (`c1234…`) or the full prefixed form.
171
+ */
172
+ function normalizeContactResource(input) {
173
+ if (!input)
174
+ throw new Error("googleContacts: contact ID is required");
175
+ return input.startsWith("people/") ? input : `people/${input}`;
176
+ }
177
+ function normalizeGroupResource(input) {
178
+ if (!input)
179
+ throw new Error("googleContacts: group ID is required");
180
+ return input.startsWith("contactGroups/") ? input : `contactGroups/${input}`;
181
+ }
182
+ function contactIdFromResource(resourceName) {
183
+ if (!resourceName)
184
+ return undefined;
185
+ const parts = resourceName.split("/");
186
+ return parts[parts.length - 1];
187
+ }
188
+ /**
189
+ * Resolve a `fields: "*" | string | string[]` argument to a
190
+ * comma-separated list of valid People-API field names.
191
+ */
192
+ function resolvePersonFields(input) {
193
+ if (!input)
194
+ return "names,emailAddresses,phoneNumbers";
195
+ if (Array.isArray(input)) {
196
+ if (input.includes("*"))
197
+ return ALL_PERSON_FIELDS.join(",");
198
+ return input.join(",");
199
+ }
200
+ if (typeof input === "string") {
201
+ if (input === "*")
202
+ return ALL_PERSON_FIELDS.join(",");
203
+ return input;
204
+ }
205
+ return "names,emailAddresses,phoneNumbers";
206
+ }
207
+ /**
208
+ * Accept an ISO date / timestamp string or a `{year, month, day}`
209
+ * object and return the Google-native structured date shape. A
210
+ * `year: 0` indicates "no year" (common for recurring events).
211
+ */
212
+ function coerceDate(v) {
213
+ if (v && typeof v === "object" && "day" in v && "month" in v) {
214
+ return v;
215
+ }
216
+ if (typeof v === "string") {
217
+ const d = new Date(v);
218
+ if (Number.isNaN(d.getTime())) {
219
+ throw new Error(`googleContacts: invalid date "${v}"`);
220
+ }
221
+ return {
222
+ year: d.getUTCFullYear(),
223
+ month: d.getUTCMonth() + 1,
224
+ day: d.getUTCDate(),
225
+ };
226
+ }
227
+ throw new Error("googleContacts: date must be an ISO string or {year, month, day}");
228
+ }
229
+ /**
230
+ * Build a People-API `Person` body from our flat-ish input shape.
231
+ * Returns the body alongside the list of `personFields` that were
232
+ * actually populated — used for `updatePersonFields` on PATCH.
233
+ */
234
+ function buildPersonBody(input, etag) {
235
+ const body = {};
236
+ const touched = [];
237
+ if (etag)
238
+ body.etag = etag;
239
+ const hasName = input.givenName !== undefined ||
240
+ input.familyName !== undefined ||
241
+ input.middleName !== undefined ||
242
+ input.honorificPrefix !== undefined ||
243
+ input.honorificSuffix !== undefined;
244
+ if (hasName) {
245
+ const name = {};
246
+ if (input.givenName !== undefined)
247
+ name.givenName = input.givenName;
248
+ if (input.familyName !== undefined)
249
+ name.familyName = input.familyName;
250
+ if (input.middleName !== undefined)
251
+ name.middleName = input.middleName;
252
+ if (input.honorificPrefix !== undefined)
253
+ name.honorificPrefix = input.honorificPrefix;
254
+ if (input.honorificSuffix !== undefined)
255
+ name.honorificSuffix = input.honorificSuffix;
256
+ body.names = [name];
257
+ touched.push("names");
258
+ }
259
+ if (input.phoneNumbers) {
260
+ body.phoneNumbers = input.phoneNumbers;
261
+ touched.push("phoneNumbers");
262
+ }
263
+ if (input.emailAddresses) {
264
+ body.emailAddresses = input.emailAddresses;
265
+ touched.push("emailAddresses");
266
+ }
267
+ if (input.addresses) {
268
+ body.addresses = input.addresses;
269
+ touched.push("addresses");
270
+ }
271
+ if (input.organizations) {
272
+ body.organizations = input.organizations;
273
+ touched.push("organizations");
274
+ }
275
+ if (input.relations) {
276
+ body.relations = input.relations;
277
+ touched.push("relations");
278
+ }
279
+ if (input.urls) {
280
+ body.urls = input.urls;
281
+ touched.push("urls");
282
+ }
283
+ if (input.events) {
284
+ body.events = input.events.map((e) => ({
285
+ date: coerceDate(e.date),
286
+ ...(e.type ? { type: e.type } : {}),
287
+ }));
288
+ touched.push("events");
289
+ }
290
+ if (input.birthday !== undefined) {
291
+ body.birthdays = [{ date: coerceDate(input.birthday) }];
292
+ touched.push("birthdays");
293
+ }
294
+ if (input.biography !== undefined) {
295
+ body.biographies = [{ value: input.biography, contentType: "TEXT_PLAIN" }];
296
+ touched.push("biographies");
297
+ }
298
+ if (input.userDefined) {
299
+ body.userDefined = input.userDefined;
300
+ touched.push("userDefined");
301
+ }
302
+ if (input.groups) {
303
+ body.memberships = input.groups.map((g) => ({
304
+ contactGroupMembership: {
305
+ contactGroupResourceName: normalizeGroupResource(g),
306
+ },
307
+ }));
308
+ touched.push("memberships");
309
+ }
310
+ return { body, touchedFields: touched };
311
+ }
312
+ function attachContactId(obj) {
313
+ return { ...obj, contactId: contactIdFromResource(obj.resourceName) };
314
+ }
315
+ // ─── Plugin ──────────────────────────────────────────────────────
316
+ const SCOPES = ["https://www.googleapis.com/auth/contacts"];
317
+ export default function googleContacts(rl) {
318
+ rl.setName("googleContacts");
319
+ rl.setVersion("0.1.0");
320
+ rl.setOAuth({
321
+ authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
322
+ tokenUrl: "https://oauth2.googleapis.com/token",
323
+ scopes: SCOPES,
324
+ authParams: { access_type: "offline", prompt: "consent" },
325
+ setupHelp: [
326
+ "You need a Google Cloud OAuth client. Takes ~5 minutes, one time.",
327
+ "",
328
+ "1. Create or pick a Google Cloud project:",
329
+ " https://console.cloud.google.com/projectcreate",
330
+ "",
331
+ "2. Enable the People API:",
332
+ " https://console.cloud.google.com/apis/library/people.googleapis.com",
333
+ "",
334
+ "3. Configure the OAuth consent screen:",
335
+ " https://console.cloud.google.com/apis/credentials/consent",
336
+ " • Audience: External",
337
+ "",
338
+ "4. Add yourself as a test user:",
339
+ " https://console.cloud.google.com/auth/audience",
340
+ "",
341
+ "5. Create the OAuth client:",
342
+ " https://console.cloud.google.com/apis/credentials",
343
+ " • + Create credentials → OAuth client ID",
344
+ " • Application type: Web application",
345
+ " • Authorized redirect URIs → + Add URI: {{redirectUri}}",
346
+ "",
347
+ "6. Paste the Client ID and Client Secret below, or export",
348
+ " GOOGLE_CONTACTS_CLIENT_ID and GOOGLE_CONTACTS_CLIENT_SECRET.",
349
+ ],
350
+ });
351
+ rl.setConnectionSchema({
352
+ clientId: { type: "string", required: true, env: "GOOGLE_CONTACTS_CLIENT_ID" },
353
+ clientSecret: { type: "string", required: true, env: "GOOGLE_CONTACTS_CLIENT_SECRET" },
354
+ refreshToken: { type: "string", required: true, env: "GOOGLE_CONTACTS_REFRESH_TOKEN" },
355
+ accessToken: { type: "string", required: false },
356
+ accessTokenExpiresAt: { type: "number", required: false },
357
+ });
358
+ // ── Contact ───────────────────────────────────────────
359
+ rl.registerAction("contact.create", {
360
+ description: "Create a new contact",
361
+ inputSchema: {
362
+ givenName: { type: "string", required: false },
363
+ familyName: { type: "string", required: false },
364
+ middleName: { type: "string", required: false },
365
+ honorificPrefix: { type: "string", required: false },
366
+ honorificSuffix: { type: "string", required: false },
367
+ phoneNumbers: {
368
+ type: "array",
369
+ required: false,
370
+ description: "[{value, type?}]",
371
+ },
372
+ emailAddresses: {
373
+ type: "array",
374
+ required: false,
375
+ description: "[{value, type?}]",
376
+ },
377
+ addresses: {
378
+ type: "array",
379
+ required: false,
380
+ description: "[{formattedValue?, streetAddress?, city?, region?, postalCode?, country?, type?}]",
381
+ },
382
+ organizations: {
383
+ type: "array",
384
+ required: false,
385
+ description: "[{name, title?, department?, type?}]",
386
+ },
387
+ relations: {
388
+ type: "array",
389
+ required: false,
390
+ description: "[{person, type?}]",
391
+ },
392
+ urls: { type: "array", required: false },
393
+ events: {
394
+ type: "array",
395
+ required: false,
396
+ description: "[{date, type?}] — date can be ISO string or {year, month, day}",
397
+ },
398
+ birthday: {
399
+ type: "string",
400
+ required: false,
401
+ description: "ISO date string or {year, month, day}",
402
+ },
403
+ biography: { type: "string", required: false },
404
+ userDefined: {
405
+ type: "array",
406
+ required: false,
407
+ description: "[{key, value}] — custom key/value pairs",
408
+ },
409
+ groups: {
410
+ type: "array",
411
+ required: false,
412
+ description: "Group IDs or full contactGroups/… resource names",
413
+ },
414
+ },
415
+ async execute(input, ctx) {
416
+ const p = (input ?? {});
417
+ const { body } = buildPersonBody(p);
418
+ const res = (await peopleRequest(ctx, "POST", "/people:createContact", body));
419
+ return attachContactId(res);
420
+ },
421
+ });
422
+ rl.registerAction("contact.get", {
423
+ description: "Get a contact by ID",
424
+ inputSchema: {
425
+ contactId: {
426
+ type: "string",
427
+ required: true,
428
+ description: "Bare ID or full people/… resource name",
429
+ },
430
+ fields: {
431
+ type: "string",
432
+ required: false,
433
+ description: "'*' (all), comma-separated string, or array of People-API field names (default: names, emailAddresses, phoneNumbers)",
434
+ },
435
+ },
436
+ async execute(input, ctx) {
437
+ const p = (input ?? {});
438
+ const resource = normalizeContactResource(p.contactId);
439
+ const res = (await peopleRequest(ctx, "GET", `/${resource}`, undefined, { personFields: resolvePersonFields(p.fields) }));
440
+ return attachContactId(res);
441
+ },
442
+ });
443
+ rl.registerAction("contact.list", {
444
+ description: "List contacts (people/me/connections) or search them. When `query` is set, hits people:searchContacts; otherwise returns the user's connections.",
445
+ inputSchema: {
446
+ query: {
447
+ type: "string",
448
+ required: false,
449
+ description: "If set, uses people:searchContacts instead of connections.list",
450
+ },
451
+ fields: { type: "string", required: false },
452
+ sortOrder: {
453
+ type: "string",
454
+ required: false,
455
+ description: "LAST_MODIFIED_ASCENDING | LAST_MODIFIED_DESCENDING | FIRST_NAME_ASCENDING | LAST_NAME_ASCENDING (connections.list only)",
456
+ },
457
+ returnAll: { type: "boolean", required: false },
458
+ maxResults: { type: "number", required: false },
459
+ pageToken: { type: "string", required: false },
460
+ },
461
+ async execute(input, ctx) {
462
+ const p = (input ?? {});
463
+ const useSearch = typeof p.query === "string" && p.query.length > 0;
464
+ const endpoint = useSearch ? "/people:searchContacts" : "/people/me/connections";
465
+ const fields = resolvePersonFields(p.fields);
466
+ const qs = {};
467
+ if (useSearch) {
468
+ qs.query = p.query;
469
+ qs.readMask = fields;
470
+ }
471
+ else {
472
+ qs.personFields = fields;
473
+ if (p.sortOrder)
474
+ qs.sortOrder = p.sortOrder;
475
+ }
476
+ if (p.pageToken)
477
+ qs.pageToken = p.pageToken;
478
+ // The People API caches searches server-side; first call often
479
+ // returns empty. Warm up when searching. Not needed for
480
+ // connections.list but cheap and identical request otherwise.
481
+ if (useSearch && !p.pageToken) {
482
+ await peopleRequest(ctx, "GET", endpoint, undefined, {
483
+ query: "",
484
+ readMask: "names",
485
+ });
486
+ }
487
+ const extractItems = (page) => {
488
+ if (useSearch) {
489
+ const results = page.results ?? [];
490
+ return results.map((r) => r.person);
491
+ }
492
+ return page.connections ?? [];
493
+ };
494
+ if (p.returnAll) {
495
+ // Use paginateAll-ish loop that knows about the two shapes.
496
+ const out = [];
497
+ const query = { ...qs, pageSize: 100 };
498
+ do {
499
+ const page = (await peopleRequest(ctx, "GET", endpoint, undefined, query));
500
+ out.push(...extractItems(page));
501
+ query.pageToken = page.nextPageToken;
502
+ } while (query.pageToken);
503
+ return out.map((c) => attachContactId(c));
504
+ }
505
+ if (p.maxResults)
506
+ qs.pageSize = p.maxResults;
507
+ const page = (await peopleRequest(ctx, "GET", endpoint, undefined, qs));
508
+ const items = extractItems(page);
509
+ return items.map((c) => attachContactId(c));
510
+ },
511
+ });
512
+ rl.registerAction("contact.update", {
513
+ description: "Update a contact. Only supplied fields are sent; etag is resolved automatically if not provided.",
514
+ inputSchema: {
515
+ contactId: { type: "string", required: true },
516
+ etag: {
517
+ type: "string",
518
+ required: false,
519
+ description: "Optimistic-concurrency tag. If omitted, the plugin fetches the latest etag first.",
520
+ },
521
+ fields: {
522
+ type: "string",
523
+ required: false,
524
+ description: "personFields projection on the response (same semantics as contact.get)",
525
+ },
526
+ // Writable fields
527
+ givenName: { type: "string", required: false },
528
+ familyName: { type: "string", required: false },
529
+ middleName: { type: "string", required: false },
530
+ honorificPrefix: { type: "string", required: false },
531
+ honorificSuffix: { type: "string", required: false },
532
+ phoneNumbers: { type: "array", required: false },
533
+ emailAddresses: { type: "array", required: false },
534
+ addresses: { type: "array", required: false },
535
+ organizations: { type: "array", required: false },
536
+ relations: { type: "array", required: false },
537
+ urls: { type: "array", required: false },
538
+ events: { type: "array", required: false },
539
+ birthday: { type: "string", required: false },
540
+ biography: { type: "string", required: false },
541
+ userDefined: { type: "array", required: false },
542
+ groups: { type: "array", required: false },
543
+ },
544
+ async execute(input, ctx) {
545
+ const p = (input ?? {});
546
+ const resource = normalizeContactResource(p.contactId);
547
+ let etag = p.etag;
548
+ if (!etag) {
549
+ const existing = (await peopleRequest(ctx, "GET", `/${resource}`, undefined, { personFields: "names" }));
550
+ etag = existing.etag;
551
+ }
552
+ const { body, touchedFields } = buildPersonBody(p, etag);
553
+ // Filter to fields People API actually accepts on updatePersonFields.
554
+ const updateMask = touchedFields.filter((f) => UPDATABLE_PERSON_FIELDS.has(f));
555
+ if (updateMask.length === 0) {
556
+ throw new Error("googleContacts: no updatable fields supplied");
557
+ }
558
+ const qs = {
559
+ updatePersonFields: updateMask.join(","),
560
+ personFields: resolvePersonFields(p.fields),
561
+ };
562
+ const res = (await peopleRequest(ctx, "PATCH", `/${resource}:updateContact`, body, qs));
563
+ return attachContactId(res);
564
+ },
565
+ });
566
+ rl.registerAction("contact.delete", {
567
+ description: "Delete a contact",
568
+ inputSchema: { contactId: { type: "string", required: true } },
569
+ async execute(input, ctx) {
570
+ const p = (input ?? {});
571
+ const resource = normalizeContactResource(p.contactId);
572
+ await peopleRequest(ctx, "DELETE", `/${resource}:deleteContact`);
573
+ return { success: true };
574
+ },
575
+ });
576
+ // ── Contact groups ────────────────────────────────────
577
+ rl.registerAction("group.list", {
578
+ description: "List contact groups (including system groups like 'myContacts' and 'starred')",
579
+ inputSchema: {
580
+ returnAll: { type: "boolean", required: false },
581
+ maxResults: { type: "number", required: false },
582
+ pageToken: { type: "string", required: false },
583
+ syncToken: { type: "string", required: false },
584
+ },
585
+ async execute(input, ctx) {
586
+ const p = (input ?? {});
587
+ const qs = {};
588
+ if (p.pageToken)
589
+ qs.pageToken = p.pageToken;
590
+ if (p.syncToken)
591
+ qs.syncToken = p.syncToken;
592
+ if (p.returnAll)
593
+ return paginateAll(ctx, "/contactGroups", "contactGroups", qs);
594
+ if (p.maxResults)
595
+ qs.pageSize = p.maxResults;
596
+ const res = (await peopleRequest(ctx, "GET", "/contactGroups", undefined, qs));
597
+ return res.contactGroups ?? [];
598
+ },
599
+ });
600
+ rl.registerAction("group.get", {
601
+ description: "Get a contact group by ID",
602
+ inputSchema: {
603
+ groupId: { type: "string", required: true },
604
+ maxMembers: {
605
+ type: "number",
606
+ required: false,
607
+ description: "Include up to N member resourceNames in the response",
608
+ },
609
+ },
610
+ async execute(input, ctx) {
611
+ const p = (input ?? {});
612
+ const resource = normalizeGroupResource(p.groupId);
613
+ const qs = {};
614
+ if (p.maxMembers !== undefined)
615
+ qs.maxMembers = p.maxMembers;
616
+ return peopleRequest(ctx, "GET", `/${resource}`, undefined, qs);
617
+ },
618
+ });
619
+ rl.registerAction("group.create", {
620
+ description: "Create a contact group",
621
+ inputSchema: {
622
+ name: { type: "string", required: true },
623
+ clientData: {
624
+ type: "array",
625
+ required: false,
626
+ description: "[{key, value}] — app-private metadata",
627
+ },
628
+ },
629
+ async execute(input, ctx) {
630
+ const p = (input ?? {});
631
+ const body = {
632
+ contactGroup: {
633
+ name: p.name,
634
+ ...(p.clientData ? { clientData: p.clientData } : {}),
635
+ },
636
+ };
637
+ return peopleRequest(ctx, "POST", "/contactGroups", body);
638
+ },
639
+ });
640
+ rl.registerAction("group.update", {
641
+ description: "Update a contact group. Pass a fresh `etag` or let the plugin resolve it automatically.",
642
+ inputSchema: {
643
+ groupId: { type: "string", required: true },
644
+ name: { type: "string", required: false },
645
+ clientData: { type: "array", required: false },
646
+ etag: { type: "string", required: false },
647
+ },
648
+ async execute(input, ctx) {
649
+ const p = (input ?? {});
650
+ const resource = normalizeGroupResource(p.groupId);
651
+ let etag = p.etag;
652
+ if (!etag) {
653
+ const existing = (await peopleRequest(ctx, "GET", `/${resource}`));
654
+ etag = existing.etag;
655
+ }
656
+ const updateGroupFields = [];
657
+ const contactGroup = { etag };
658
+ if (p.name !== undefined) {
659
+ contactGroup.name = p.name;
660
+ updateGroupFields.push("name");
661
+ }
662
+ if (p.clientData !== undefined) {
663
+ contactGroup.clientData = p.clientData;
664
+ updateGroupFields.push("clientData");
665
+ }
666
+ if (updateGroupFields.length === 0) {
667
+ throw new Error("googleContacts: nothing to update on group");
668
+ }
669
+ return peopleRequest(ctx, "PUT", `/${resource}`, {
670
+ contactGroup,
671
+ updateGroupFields: updateGroupFields.join(","),
672
+ });
673
+ },
674
+ });
675
+ rl.registerAction("group.delete", {
676
+ description: "Delete a contact group. Pass `deleteContacts=true` to also delete every contact in the group.",
677
+ inputSchema: {
678
+ groupId: { type: "string", required: true },
679
+ deleteContacts: { type: "boolean", required: false },
680
+ },
681
+ async execute(input, ctx) {
682
+ const p = (input ?? {});
683
+ const resource = normalizeGroupResource(p.groupId);
684
+ const qs = {};
685
+ if (p.deleteContacts)
686
+ qs.deleteContacts = p.deleteContacts;
687
+ await peopleRequest(ctx, "DELETE", `/${resource}`, undefined, qs);
688
+ return { success: true };
689
+ },
690
+ });
691
+ }