@slates/google-people-recipes 1.0.0-rc.1

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.
package/src/index.ts ADDED
@@ -0,0 +1,559 @@
1
+ import { ServiceError, badRequestError } from '@lowerdeck/error';
2
+ import { defineToolRecipe } from '@slates/tool-recipes';
3
+ import { anyOf, createAxios } from 'slates';
4
+ import { z } from 'zod';
5
+
6
+ export let GOOGLE_PEOPLE_API_BASE_URL = 'https://people.googleapis.com/v1/';
7
+
8
+ export type GooglePeopleApi = {
9
+ get: (url: string, config?: any) => Promise<{ data: any }>;
10
+ };
11
+
12
+ let peopleAxios = createAxios({
13
+ baseURL: GOOGLE_PEOPLE_API_BASE_URL
14
+ }) as GooglePeopleApi;
15
+
16
+ export let googlePeopleScopes = {
17
+ contactsReadonly: 'https://www.googleapis.com/auth/contacts.readonly'
18
+ } as const;
19
+
20
+ export let googlePeopleReadonlyScopes = anyOf(googlePeopleScopes.contactsReadonly);
21
+
22
+ export let DEFAULT_PERSON_FIELDS =
23
+ 'names,emailAddresses,phoneNumbers,addresses,organizations,birthdays,urls,biographies,events,genders,occupations,nicknames,relations,userDefined,memberships';
24
+
25
+ export let READONLY_PERSON_FIELDS = 'names,emailAddresses,phoneNumbers';
26
+
27
+ export interface ContactInput {
28
+ names?: Array<{
29
+ givenName?: string;
30
+ familyName?: string;
31
+ middleName?: string;
32
+ prefix?: string;
33
+ suffix?: string;
34
+ }>;
35
+ emailAddresses?: Array<{ value: string; type?: string }>;
36
+ phoneNumbers?: Array<{ value: string; type?: string }>;
37
+ addresses?: Array<{
38
+ streetAddress?: string;
39
+ city?: string;
40
+ region?: string;
41
+ postalCode?: string;
42
+ country?: string;
43
+ type?: string;
44
+ }>;
45
+ organizations?: Array<{
46
+ name?: string;
47
+ title?: string;
48
+ department?: string;
49
+ }>;
50
+ birthdays?: Array<{ date?: { year?: number; month?: number; day?: number } }>;
51
+ urls?: Array<{ value: string; type?: string }>;
52
+ biographies?: Array<{ value: string }>;
53
+ userDefined?: Array<{ key: string; value: string }>;
54
+ nicknames?: Array<{ value: string }>;
55
+ relations?: Array<{ person: string; type?: string }>;
56
+ events?: Array<{ date?: { year?: number; month?: number; day?: number }; type?: string }>;
57
+ occupations?: Array<{ value: string }>;
58
+ }
59
+
60
+ export let nameSchema = z
61
+ .object({
62
+ displayName: z.string().optional().describe('Full display name'),
63
+ givenName: z.string().optional().describe('First/given name'),
64
+ familyName: z.string().optional().describe('Last/family name'),
65
+ middleName: z.string().optional().describe('Middle name'),
66
+ prefix: z.string().optional().describe('Name prefix (e.g., Mr., Dr.)'),
67
+ suffix: z.string().optional().describe('Name suffix (e.g., Jr., III)')
68
+ })
69
+ .describe('Name of the contact');
70
+
71
+ export let emailSchema = z
72
+ .object({
73
+ value: z.string().describe('Email address'),
74
+ type: z.string().optional().describe('Type of email (e.g., home, work, other)')
75
+ })
76
+ .describe('Email address');
77
+
78
+ export let phoneSchema = z
79
+ .object({
80
+ value: z.string().describe('Phone number'),
81
+ type: z.string().optional().describe('Type of phone (e.g., home, work, mobile)')
82
+ })
83
+ .describe('Phone number');
84
+
85
+ export let addressSchema = z
86
+ .object({
87
+ streetAddress: z.string().optional().describe('Street address'),
88
+ city: z.string().optional().describe('City'),
89
+ region: z.string().optional().describe('State or region'),
90
+ postalCode: z.string().optional().describe('Postal/ZIP code'),
91
+ country: z.string().optional().describe('Country'),
92
+ type: z.string().optional().describe('Type of address (e.g., home, work)')
93
+ })
94
+ .describe('Physical address');
95
+
96
+ export let organizationSchema = z
97
+ .object({
98
+ name: z.string().optional().describe('Organization name'),
99
+ title: z.string().optional().describe('Job title'),
100
+ department: z.string().optional().describe('Department name')
101
+ })
102
+ .describe('Organization/company');
103
+
104
+ export let dateFieldSchema = z
105
+ .object({
106
+ year: z.number().optional().describe('Year (omit for recurring events)'),
107
+ month: z.number().optional().describe('Month (1-12)'),
108
+ day: z.number().optional().describe('Day of month (1-31)')
109
+ })
110
+ .describe('Date');
111
+
112
+ export let birthdaySchema = z
113
+ .object({
114
+ date: dateFieldSchema.optional()
115
+ })
116
+ .describe('Birthday');
117
+
118
+ export let urlSchema = z
119
+ .object({
120
+ value: z.string().describe('URL'),
121
+ type: z.string().optional().describe('Type of URL (e.g., homePage, blog, profile)')
122
+ })
123
+ .describe('URL');
124
+
125
+ export let biographySchema = z
126
+ .object({
127
+ value: z.string().describe('Biography or notes text')
128
+ })
129
+ .describe('Biography/notes');
130
+
131
+ export let customFieldSchema = z
132
+ .object({
133
+ key: z.string().describe('Custom field label'),
134
+ value: z.string().describe('Custom field value')
135
+ })
136
+ .describe('Custom field');
137
+
138
+ export let nicknameSchema = z
139
+ .object({
140
+ value: z.string().describe('Nickname')
141
+ })
142
+ .describe('Nickname');
143
+
144
+ export let relationSchema = z
145
+ .object({
146
+ person: z.string().describe('Name of the related person'),
147
+ type: z
148
+ .string()
149
+ .optional()
150
+ .describe('Relationship type (e.g., spouse, parent, child, friend)')
151
+ })
152
+ .describe('Relationship');
153
+
154
+ export let eventSchema = z
155
+ .object({
156
+ date: dateFieldSchema.optional(),
157
+ type: z.string().optional().describe('Type of event (e.g., anniversary)')
158
+ })
159
+ .describe('Event/date');
160
+
161
+ export let occupationSchema = z
162
+ .object({
163
+ value: z.string().describe('Occupation')
164
+ })
165
+ .describe('Occupation');
166
+
167
+ export let membershipSchema = z
168
+ .object({
169
+ contactGroupResourceName: z
170
+ .string()
171
+ .optional()
172
+ .describe('Resource name of the contact group')
173
+ })
174
+ .describe('Group membership');
175
+
176
+ export let contactInputSchema = z.object({
177
+ names: z.array(nameSchema).optional().describe('Names for the contact'),
178
+ emailAddresses: z.array(emailSchema).optional().describe('Email addresses'),
179
+ phoneNumbers: z.array(phoneSchema).optional().describe('Phone numbers'),
180
+ addresses: z.array(addressSchema).optional().describe('Physical addresses'),
181
+ organizations: z.array(organizationSchema).optional().describe('Organizations/companies'),
182
+ birthdays: z.array(birthdaySchema).optional().describe('Birthdays'),
183
+ urls: z.array(urlSchema).optional().describe('URLs'),
184
+ biographies: z.array(biographySchema).optional().describe('Biographies/notes'),
185
+ userDefined: z.array(customFieldSchema).optional().describe('Custom fields'),
186
+ nicknames: z.array(nicknameSchema).optional().describe('Nicknames'),
187
+ relations: z.array(relationSchema).optional().describe('Relationships'),
188
+ events: z.array(eventSchema).optional().describe('Events/dates'),
189
+ occupations: z.array(occupationSchema).optional().describe('Occupations')
190
+ });
191
+
192
+ export let contactOutputSchema = z.object({
193
+ resourceName: z.string().describe('Unique resource name (e.g., people/c12345)'),
194
+ etag: z.string().optional().describe('ETag for concurrency control, required when updating'),
195
+ names: z.array(nameSchema).optional(),
196
+ emailAddresses: z.array(emailSchema).optional(),
197
+ phoneNumbers: z.array(phoneSchema).optional(),
198
+ addresses: z.array(addressSchema).optional(),
199
+ organizations: z.array(organizationSchema).optional(),
200
+ birthdays: z.array(birthdaySchema).optional(),
201
+ urls: z.array(urlSchema).optional(),
202
+ biographies: z.array(biographySchema).optional(),
203
+ userDefined: z.array(customFieldSchema).optional(),
204
+ nicknames: z.array(nicknameSchema).optional(),
205
+ relations: z.array(relationSchema).optional(),
206
+ events: z.array(eventSchema).optional(),
207
+ occupations: z.array(occupationSchema).optional(),
208
+ memberships: z.array(membershipSchema).optional()
209
+ });
210
+
211
+ export let listContactsInputSchema = z.object({
212
+ pageSize: z
213
+ .number()
214
+ .optional()
215
+ .describe('Number of contacts to return per page (max 1000, default 100)'),
216
+ pageToken: z.string().optional().describe('Token for fetching the next page of results'),
217
+ sortOrder: z
218
+ .enum([
219
+ 'LAST_MODIFIED_ASCENDING',
220
+ 'LAST_MODIFIED_DESCENDING',
221
+ 'FIRST_NAME_ASCENDING',
222
+ 'LAST_NAME_ASCENDING'
223
+ ])
224
+ .optional()
225
+ .describe('Sort order for the results')
226
+ });
227
+
228
+ export let listContactsOutputSchema = z.object({
229
+ contacts: z.array(contactOutputSchema).describe('List of contacts'),
230
+ nextPageToken: z.string().optional().describe('Token for fetching the next page'),
231
+ totalPeople: z.number().optional().describe('Total number of contacts'),
232
+ totalItems: z.number().optional().describe('Total number of items in response')
233
+ });
234
+
235
+ export let searchContactsInputSchema = z.object({
236
+ query: z
237
+ .string()
238
+ .describe(
239
+ 'Search query — matches against names, emails, phone numbers, and other contact fields'
240
+ ),
241
+ pageSize: z.number().optional().describe('Maximum number of results to return (default 30)')
242
+ });
243
+
244
+ export let searchContactsOutputSchema = z.object({
245
+ contacts: z.array(contactOutputSchema).describe('Matching contacts')
246
+ });
247
+
248
+ export let getContactInputSchema = z.object({
249
+ resourceName: z
250
+ .string()
251
+ .describe('Resource name of the contact (e.g., "people/c12345" or "people/me")')
252
+ });
253
+
254
+ export let formatContact = (person: any) => {
255
+ return {
256
+ resourceName: person.resourceName,
257
+ etag: person.etag,
258
+ names: person.names,
259
+ emailAddresses: person.emailAddresses,
260
+ phoneNumbers: person.phoneNumbers,
261
+ addresses: person.addresses,
262
+ organizations: person.organizations,
263
+ birthdays: person.birthdays,
264
+ urls: person.urls,
265
+ biographies: person.biographies,
266
+ userDefined: person.userDefined,
267
+ nicknames: person.nicknames,
268
+ relations: person.relations,
269
+ events: person.events,
270
+ occupations: person.occupations,
271
+ memberships: person.memberships?.map((membership: any) => ({
272
+ contactGroupResourceName: membership.contactGroupMembership?.contactGroupResourceName
273
+ }))
274
+ };
275
+ };
276
+
277
+ type ErrorResponse = {
278
+ status?: number;
279
+ statusText?: string;
280
+ data?: unknown;
281
+ };
282
+
283
+ let isRecord = (value: unknown): value is Record<string, unknown> =>
284
+ typeof value === 'object' && value !== null;
285
+
286
+ let addMessage = (messages: string[], value: unknown) => {
287
+ if (typeof value !== 'string') return;
288
+ let trimmed = value.trim();
289
+ if (trimmed && !messages.includes(trimmed)) {
290
+ messages.push(trimmed);
291
+ }
292
+ };
293
+
294
+ let collectMessages = (value: unknown, messages: string[]) => {
295
+ if (!isRecord(value)) {
296
+ addMessage(messages, value);
297
+ return;
298
+ }
299
+
300
+ for (let key of ['message', 'error_description', 'error', 'detail', 'reason']) {
301
+ addMessage(messages, value[key]);
302
+ }
303
+
304
+ let nestedError = value.error;
305
+ if (isRecord(nestedError)) {
306
+ collectMessages(nestedError, messages);
307
+ }
308
+
309
+ let errors = value.errors;
310
+ if (Array.isArray(errors)) {
311
+ for (let item of errors) {
312
+ collectMessages(item, messages);
313
+ }
314
+ }
315
+ };
316
+
317
+ let extractGooglePeopleMessage = (error: unknown) => {
318
+ let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined;
319
+ let messages: string[] = [];
320
+
321
+ collectMessages(response?.data, messages);
322
+
323
+ if (isRecord(error)) {
324
+ collectMessages(error.data, messages);
325
+ }
326
+
327
+ if (messages.length > 0) {
328
+ return messages.join(' - ');
329
+ }
330
+
331
+ if (error instanceof Error && error.message) {
332
+ return error.message;
333
+ }
334
+
335
+ return 'Unknown error';
336
+ };
337
+
338
+ let getErrorStatus = (error: unknown) => {
339
+ if (!isRecord(error)) return undefined;
340
+ let response = error.response as ErrorResponse | undefined;
341
+ if (typeof response?.status === 'number') return response.status;
342
+ if (typeof error.status === 'number') return error.status;
343
+ if (isRecord(error.data) && typeof error.data.status === 'number') return error.data.status;
344
+ if (isRecord(error.upstream) && typeof error.upstream.status === 'number') {
345
+ return error.upstream.status;
346
+ }
347
+ return undefined;
348
+ };
349
+
350
+ let getErrorStatusText = (error: unknown) => {
351
+ if (!isRecord(error)) return undefined;
352
+ let response = error.response as ErrorResponse | undefined;
353
+ if (typeof response?.statusText === 'string') return response.statusText;
354
+ if (isRecord(error.upstream) && typeof error.upstream.statusText === 'string') {
355
+ return error.upstream.statusText;
356
+ }
357
+ return undefined;
358
+ };
359
+
360
+ export let googlePeopleServiceError = (message: string) =>
361
+ new ServiceError(badRequestError({ message }));
362
+
363
+ export let googlePeopleApiError = (error: unknown, operation = 'request') => {
364
+ if (error instanceof ServiceError) {
365
+ return error;
366
+ }
367
+
368
+ let status = getErrorStatus(error);
369
+ let statusText = getErrorStatusText(error);
370
+ let statusLabel =
371
+ status !== undefined ? `HTTP ${status}${statusText ? ` ${statusText}` : ''}: ` : '';
372
+ let serviceError = googlePeopleServiceError(
373
+ `Google People API ${operation} failed: ${statusLabel}${extractGooglePeopleMessage(error)}`
374
+ );
375
+
376
+ serviceError.data.reason = 'google_people_api_error';
377
+ serviceError.data.upstreamStatus = status;
378
+
379
+ if (error instanceof Error) {
380
+ serviceError.setParent(error);
381
+ }
382
+
383
+ return serviceError;
384
+ };
385
+
386
+ export class GooglePeopleClient {
387
+ private api: GooglePeopleApi;
388
+
389
+ constructor(private config: { token: string; api?: GooglePeopleApi }) {
390
+ this.api = config.api ?? peopleAxios;
391
+ }
392
+
393
+ private headers() {
394
+ return {
395
+ Authorization: `Bearer ${this.config.token}`
396
+ };
397
+ }
398
+
399
+ async getContact(resourceName: string, personFields?: string) {
400
+ try {
401
+ let response = await this.api.get(resourceName, {
402
+ params: { personFields: personFields || DEFAULT_PERSON_FIELDS },
403
+ headers: this.headers()
404
+ });
405
+ return response.data;
406
+ } catch (error) {
407
+ throw googlePeopleApiError(error, 'get contact');
408
+ }
409
+ }
410
+
411
+ async listContacts(params: {
412
+ pageSize?: number;
413
+ pageToken?: string;
414
+ sortOrder?: string;
415
+ personFields?: string;
416
+ syncToken?: string;
417
+ requestSyncToken?: boolean;
418
+ }) {
419
+ try {
420
+ let response = await this.api.get('people/me/connections', {
421
+ params: {
422
+ personFields: params.personFields || DEFAULT_PERSON_FIELDS,
423
+ pageSize: params.pageSize || 100,
424
+ pageToken: params.pageToken,
425
+ sortOrder: params.sortOrder,
426
+ syncToken: params.syncToken,
427
+ requestSyncToken: params.requestSyncToken
428
+ },
429
+ headers: this.headers()
430
+ });
431
+ return response.data;
432
+ } catch (error) {
433
+ throw googlePeopleApiError(error, 'list contacts');
434
+ }
435
+ }
436
+
437
+ async searchContacts(query: string, personFields?: string, pageSize?: number) {
438
+ try {
439
+ let response = await this.api.get('people:searchContacts', {
440
+ params: {
441
+ query,
442
+ readMask: personFields || DEFAULT_PERSON_FIELDS,
443
+ pageSize: pageSize || 30
444
+ },
445
+ headers: this.headers()
446
+ });
447
+ return response.data;
448
+ } catch (error) {
449
+ throw googlePeopleApiError(error, 'search contacts');
450
+ }
451
+ }
452
+ }
453
+
454
+ export type GooglePeopleRecipeDependencies = {
455
+ createClient: (ctx: { auth: { token: string } }) => GooglePeopleClient;
456
+ };
457
+
458
+ export let createGooglePeopleClient = (ctx: { auth: { token: string } }) =>
459
+ new GooglePeopleClient({ token: ctx.auth.token });
460
+
461
+ export let listContactsRecipe = defineToolRecipe({
462
+ name: 'List Contacts',
463
+ key: 'list_contacts',
464
+ description: `Lists the authenticated user's contacts with pagination support. Returns contacts sorted by the specified order. Use the \`pageToken\` from a previous response to fetch the next page.`,
465
+ tags: {
466
+ destructive: false,
467
+ readOnly: true
468
+ },
469
+ defaultScopes: googlePeopleReadonlyScopes,
470
+ inputSchema: listContactsInputSchema,
471
+ outputSchema: listContactsOutputSchema,
472
+ handleInvocation: async ({
473
+ ctx,
474
+ dependencies
475
+ }: {
476
+ ctx: any;
477
+ dependencies: GooglePeopleRecipeDependencies;
478
+ }) => {
479
+ let client = dependencies.createClient(ctx);
480
+ let result = await client.listContacts({
481
+ pageSize: ctx.input.pageSize,
482
+ pageToken: ctx.input.pageToken,
483
+ sortOrder: ctx.input.sortOrder
484
+ });
485
+ let contacts = (result.connections || []).map(formatContact);
486
+
487
+ return {
488
+ output: {
489
+ contacts,
490
+ nextPageToken: result.nextPageToken,
491
+ totalPeople: result.totalPeople,
492
+ totalItems: result.totalItems
493
+ },
494
+ message: `Listed **${contacts.length}** contacts${result.totalPeople ? ` out of ${result.totalPeople} total` : ''}.${result.nextPageToken ? ' More pages available.' : ''}`
495
+ };
496
+ }
497
+ });
498
+
499
+ export let searchContactsRecipe = defineToolRecipe({
500
+ name: 'Search Contacts',
501
+ key: 'search_contacts',
502
+ description: `Searches the authenticated user's contacts by name, email address, phone number, or other fields. Returns matching contacts ranked by relevance.`,
503
+ tags: {
504
+ destructive: false,
505
+ readOnly: true
506
+ },
507
+ defaultScopes: googlePeopleReadonlyScopes,
508
+ inputSchema: searchContactsInputSchema,
509
+ outputSchema: searchContactsOutputSchema,
510
+ handleInvocation: async ({
511
+ ctx,
512
+ dependencies
513
+ }: {
514
+ ctx: any;
515
+ dependencies: GooglePeopleRecipeDependencies;
516
+ }) => {
517
+ let client = dependencies.createClient(ctx);
518
+ let result = await client.searchContacts(ctx.input.query, undefined, ctx.input.pageSize);
519
+ let contacts = (result.results || []).map((item: any) => formatContact(item.person));
520
+
521
+ return {
522
+ output: { contacts },
523
+ message: `Found **${contacts.length}** contacts matching "${ctx.input.query}".`
524
+ };
525
+ }
526
+ });
527
+
528
+ export let getContactRecipe = defineToolRecipe({
529
+ name: 'Get Contact',
530
+ key: 'get_contact',
531
+ description: `Retrieves detailed information about a specific contact by their resource name. Use \`people/me\` to get the authenticated user's profile. Returns all available contact fields.`,
532
+ tags: {
533
+ destructive: false,
534
+ readOnly: true
535
+ },
536
+ defaultScopes: googlePeopleReadonlyScopes,
537
+ inputSchema: getContactInputSchema,
538
+ outputSchema: contactOutputSchema,
539
+ handleInvocation: async ({
540
+ ctx,
541
+ dependencies
542
+ }: {
543
+ ctx: any;
544
+ dependencies: GooglePeopleRecipeDependencies;
545
+ }) => {
546
+ let client = dependencies.createClient(ctx);
547
+ let result = await client.getContact(ctx.input.resourceName);
548
+ let contact = formatContact(result);
549
+ let displayName =
550
+ contact.names?.[0]?.displayName ||
551
+ contact.emailAddresses?.[0]?.value ||
552
+ ctx.input.resourceName;
553
+
554
+ return {
555
+ output: contact,
556
+ message: `Retrieved contact **${displayName}**.`
557
+ };
558
+ }
559
+ });