From 8dcec579582df62f5477028455b6a3128891d592 Mon Sep 17 00:00:00 2001 From: hkonsti <45428767+hkonsti@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:58:51 +0000 Subject: [PATCH] ask google for who is free, other contacts and tasks, and drop the scopes nothing uses Google's restricted-scope review turns down any scope the app doesn't visibly use, so the sign-in stops asking for documents, spreadsheets and presentations: the Docs and Slides APIs were never called, and Sheets goes through drive, which covers all three. In their place come three sensitive scopes, each with the actions that use it: - calendar.events.freebusy: See when people are free, the availability collection's Check, which asks Google's free/busy of the account and the people named and answers each one's busy times and the times all of them are free, on the primary calendar's clock. Someone whose calendar can't be seen is said to be so, never free. - contacts.other.readonly: Read other contacts, the people Gmail keeps under Other contacts, listed, searched and opened, with Save to contacts (under Add and edit contacts) copying one into Google Contacts. - tasks: Read tasks and Add and edit tasks, Google Tasks as a tree of lists and their tasks, narrowed to those to do or done; tasks are made, edited, moved to another list by its name, completed and reopened, and never deleted. The time parsing and time zone arithmetic the events collection had move to calendar-time.ts, which the availability check shares. The docs, the privacy policy (which names Google Tasks, free or busy times and other contacts), the env help and the seed follow. --- .env.example | 2 +- .../src/connections/google/calendar-api.ts | 27 +- .../src/connections/google/google-oauth.ts | 2 +- .../api/src/connections/google/people-api.ts | 50 ++- .../api/src/connections/google/route.test.ts | 10 +- packages/api/src/connections/google/scopes.ts | 21 +- .../api/src/connections/google/tasks-api.ts | 123 ++++++++ packages/api/src/connections/route.test.ts | 3 + .../api/src/connections/unavailable.test.ts | 56 +++- packages/api/src/records/calendar-time.ts | 69 ++++ .../src/records/google-availability.test.ts | 187 +++++++++++ .../api/src/records/google-availability.ts | 180 +++++++++++ packages/api/src/records/google-connector.ts | 23 +- packages/api/src/records/google-contacts.ts | 2 +- packages/api/src/records/google-events.ts | 55 +--- .../src/records/google-other-contacts.test.ts | 166 ++++++++++ .../api/src/records/google-other-contacts.ts | 95 ++++++ packages/api/src/records/google-tasks.test.ts | 296 ++++++++++++++++++ packages/api/src/records/google-tasks.ts | 272 ++++++++++++++++ packages/api/src/utils/env-schema.ts | 2 +- packages/db/scripts/seed.ts | 6 +- packages/docs/content/connections/google.mdx | 55 +++- packages/docs/content/index.mdx | 2 +- packages/integrations/src/catalog.ts | 161 +++++++++- packages/integrations/src/tools.test.ts | 61 +++- packages/integrations/src/types.ts | 4 +- .../src/components/connection-access.test.tsx | 2 + .../web/src/components/connection-access.tsx | 2 +- packages/website/src/app/privacy/page.tsx | 32 +- packages/website/src/lib/legal.ts | 2 +- 30 files changed, 1852 insertions(+), 116 deletions(-) create mode 100644 packages/api/src/connections/google/tasks-api.ts create mode 100644 packages/api/src/records/calendar-time.ts create mode 100644 packages/api/src/records/google-availability.test.ts create mode 100644 packages/api/src/records/google-availability.ts create mode 100644 packages/api/src/records/google-other-contacts.test.ts create mode 100644 packages/api/src/records/google-other-contacts.ts create mode 100644 packages/api/src/records/google-tasks.test.ts create mode 100644 packages/api/src/records/google-tasks.ts diff --git a/.env.example b/.env.example index 2bc39ea5..658b6678 100644 --- a/.env.example +++ b/.env.example @@ -15,7 +15,7 @@ ENCRYPTION_KEY="ZGV2LW9ubHktZW5jcnlwdGlvbi1rZXktMzItYnl0ZXM=" # Google sign-in, and connecting a Google account. Register http://localhost:5173/auth/google/callback # and http://localhost:5173/api/connections/google/callback as redirect URIs, and enable the Gmail, -# Calendar, Drive, Docs, Sheets, Slides and People APIs. +# Calendar, Drive, Sheets, People and Tasks APIs. # GOOGLE_CLIENT_ID="" # GOOGLE_CLIENT_SECRET="" diff --git a/packages/api/src/connections/google/calendar-api.ts b/packages/api/src/connections/google/calendar-api.ts index 86a4316c..cfb55738 100644 --- a/packages/api/src/connections/google/calendar-api.ts +++ b/packages/api/src/connections/google/calendar-api.ts @@ -6,9 +6,9 @@ import type {ApiError} from '@proxy/utils'; import {googleRequest, googleWrite} from './google-client'; /** - * The Calendar v3 calls the `events` collection makes, and the reading of their answers: the - * calendars the account has in its list, and their events, each time a repeating event comes round - * its own, as Google Calendar shows them. + * The Calendar v3 calls the `events` and `availability` collections make, and the reading of their + * answers: the calendars the account has in its list, and their events, each time a repeating event + * comes round its own, as Google Calendar shows them; and when calendars are busy. */ const CALENDAR = 'https://www.googleapis.com/calendar/v3'; @@ -151,3 +151,24 @@ export function insertEvent(accessToken: string, calendarId: string, event: Even export function patchEvent(accessToken: string, calendarId: string, eventId: string, event: EventWrite, sendUpdates: 'all' | 'none'): Promise> { return googleWrite(accessToken, 'PATCH', eventUrl(calendarId, eventId, sendUpdates), event, eventSchema); } + +const freeBusySchema = z.object({ + calendars: z + .record( + z.string(), + z.object({ + busy: z.array(z.object({start: z.string(), end: z.string()})).optional(), + // Why a calendar wasn't answered for, such as `notFound` for one the account can't see. + errors: z.array(z.object({reason: z.string().optional()})).optional(), + }), + ) + .optional(), +}); + +export type FreeBusy = z.infer; + +/** When each calendar given is busy between `from` and `to`, instants with their offsets, by the calendar's id as given. */ +export function queryFreeBusy(accessToken: string, args: {from: string; to: string; calendars: string[]}): Promise> { + const body = {timeMin: args.from, timeMax: args.to, items: args.calendars.map((id) => ({id}))}; + return googleWrite(accessToken, 'POST', new URL(`${CALENDAR}/freeBusy`), body, freeBusySchema); +} diff --git a/packages/api/src/connections/google/google-oauth.ts b/packages/api/src/connections/google/google-oauth.ts index 9fea573a..5c7b6c7e 100644 --- a/packages/api/src/connections/google/google-oauth.ts +++ b/packages/api/src/connections/google/google-oauth.ts @@ -24,7 +24,7 @@ const TIMEOUT_MS = 15_000; // Refreshed a minute early, so a token doesn't run out between the check and the call. const EXPIRY_MARGIN_MS = 60_000; -/** Every scope a sign-in asks for: the account's address, then Gmail, Calendar, Drive, Docs, Sheets, Slides and Contacts. */ +/** Every scope a sign-in asks for: the account's address, then Gmail, Calendar, Drive, Contacts and Tasks. */ export const GOOGLE_SCOPES = ['openid', 'https://www.googleapis.com/auth/userinfo.email', ...PRODUCT_SCOPES]; /** What a Google connection stores, encrypted: its tokens, the scopes Google granted, and the account. */ diff --git a/packages/api/src/connections/google/people-api.ts b/packages/api/src/connections/google/people-api.ts index deaed5d8..ae7d7e90 100644 --- a/packages/api/src/connections/google/people-api.ts +++ b/packages/api/src/connections/google/people-api.ts @@ -6,9 +6,10 @@ import {type ApiError, Do} from '@proxy/utils'; import {googleRequest, googleWrite} from './google-client'; /** - * The People v1 calls the `contacts` collection makes, and the reading of their answers: the - * account's contacts in Google Contacts, newest changed first, Google's search of them, and making - * and changing one, a field at a time. + * The People v1 calls the `contacts` and `otherContacts` collections make, and the reading of their + * answers: the account's contacts in Google Contacts, newest changed first, Google's search of them, + * and making and changing one, a field at a time; and the people Gmail keeps under Other contacts, + * listed, searched and copied into the contacts. */ const PEOPLE = 'https://people.googleapis.com/v1'; @@ -56,6 +57,12 @@ export type PersonWrite = { biographies?: Array<{value: string; contentType: 'TEXT_PLAIN'}>; }; +/** The fields an other contact is read with: all Google keeps of one. */ +const OTHER_FIELDS = 'names,emailAddresses,phoneNumbers,metadata'; + +/** The most other contacts a page of Google's list holds. */ +export const OTHER_CONTACTS_MAX_PAGE = 1000; + const connectionsSchema = z.object({connections: z.array(personSchema).optional(), nextPageToken: z.string().optional()}); const searchSchema = z.object({results: z.array(z.object({person: personSchema})).optional()}); @@ -120,3 +127,40 @@ export function updateContact(accessToken: string, before: Person, person: Perso const sources = (before.metadata?.sources ?? []).filter((source) => source.type === 'CONTACT'); return googleWrite(accessToken, 'PATCH', url, {...person, etag: before.etag, metadata: {sources}}, personSchema); } + +const otherContactsSchema = z.object({otherContacts: z.array(personSchema).optional(), nextPageToken: z.string().optional()}); + +/** A page of the account's other contacts, in Google's own order. */ +export async function listOtherContacts(accessToken: string, page: string | null, pageSize = CONTACTS_PAGE_SIZE): Promise> { + const url = new URL(`${PEOPLE}/otherContacts`); + url.searchParams.set('readMask', OTHER_FIELDS); + url.searchParams.set('pageSize', String(pageSize)); + if (page) { + url.searchParams.set('pageToken', page); + } + const answer = await googleRequest(accessToken, url, otherContactsSchema); + return answer.map(({otherContacts = [], nextPageToken}) => ({people: otherContacts, nextPage: nextPageToken ?? null})); +} + +function otherSearchUrl(query: string): URL { + const url = new URL(`${PEOPLE}/otherContacts:search`); + url.searchParams.set('query', query); + url.searchParams.set('readMask', OTHER_FIELDS); + url.searchParams.set('pageSize', String(SEARCH_SIZE)); + return url; +} + +/** The other contacts whose names, addresses or numbers begin with the words searched for, after the empty search Google asks for first. */ +export function searchOtherContacts(accessToken: string, query: string): Promise> { + return Do(async ($) => { + $(await googleRequest(accessToken, otherSearchUrl(''), searchSchema)); + const answer = $(await googleRequest(accessToken, otherSearchUrl(query), searchSchema)); + return (answer.results ?? []).map(({person}) => person); + }); +} + +/** Copies an other contact's names, addresses and numbers into a new contact in Google Contacts. */ +export function copyOtherContact(accessToken: string, resourceName: string): Promise> { + const body = {copyMask: 'names,emailAddresses,phoneNumbers', readMask: PERSON_FIELDS}; + return googleWrite(accessToken, 'POST', new URL(`${PEOPLE}/${resourceName}:copyOtherContactToMyContactsGroup`), body, personSchema); +} diff --git a/packages/api/src/connections/google/route.test.ts b/packages/api/src/connections/google/route.test.ts index aed93c15..02f7ac2a 100644 --- a/packages/api/src/connections/google/route.test.ts +++ b/packages/api/src/connections/google/route.test.ts @@ -49,11 +49,11 @@ const SCOPES = [ 'https://www.googleapis.com/auth/gmail.send', 'https://www.googleapis.com/auth/calendar.events', 'https://www.googleapis.com/auth/calendar.calendarlist.readonly', + 'https://www.googleapis.com/auth/calendar.events.freebusy', 'https://www.googleapis.com/auth/drive', - 'https://www.googleapis.com/auth/documents', - 'https://www.googleapis.com/auth/spreadsheets', - 'https://www.googleapis.com/auth/presentations', 'https://www.googleapis.com/auth/contacts', + 'https://www.googleapis.com/auth/contacts.other.readonly', + 'https://www.googleapis.com/auth/tasks', ]; function base64url(value: object): string { @@ -80,7 +80,7 @@ spyOn(globalThis, 'fetch').mockImplementation((async (input: string | URL | Requ refresh_token: 'refresh-1', expires_in: 3599, token_type: 'Bearer', - scope: 'openid https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/documents', + scope: 'openid https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/tasks', id_token: google.idToken, }); }) as typeof fetch); @@ -196,7 +196,7 @@ describe('handleGoogleCallbackRoute', () => { accessToken: 'access-1', refreshToken: 'refresh-1', expiresAt: expect.any(String), - scopes: ['openid', 'https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/drive', 'https://www.googleapis.com/auth/documents'], + scopes: ['openid', 'https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/drive', 'https://www.googleapis.com/auth/tasks'], email: 'alex@example.com', }); expect(res.redirect).toHaveBeenCalledWith('https://app.example.com/org-1/connections/conn-1?added'); diff --git a/packages/api/src/connections/google/scopes.ts b/packages/api/src/connections/google/scopes.ts index 53541711..6681f6ec 100644 --- a/packages/api/src/connections/google/scopes.ts +++ b/packages/api/src/connections/google/scopes.ts @@ -20,23 +20,32 @@ const GMAIL = [[scope('gmail.modify')]]; // read for their names and time zones, `calendar.calendarlist.readonly`. const CALENDAR = [[scope('calendar.events'), scope('calendar.calendarlist.readonly')]]; +// Who is free takes `calendar.events.freebusy`, and the primary calendar's time zone, which times +// without an offset are on, `calendar.calendarlist.readonly`. +const AVAILABILITY = [[scope('calendar.events.freebusy'), scope('calendar.calendarlist.readonly')]]; + // Drive's export and uploads and the Sheets API take `drive` for reading and writing alike, so it -// alone covers every file action. +// alone covers every file action; the Docs, Sheets and Slides scopes would add nothing to it. const DRIVE = [[scope('drive')]]; const CONTACTS = [[scope('contacts')]]; +// The people Gmail keeps under Other contacts are read with a scope of their own, only ever read. +const OTHER_CONTACTS = [[scope('contacts.other.readonly')]]; + +const TASKS = [[scope('tasks')]]; + /** Every scope a sign-in asks for beyond the account's address, in the order Google shows them. */ export const PRODUCT_SCOPES = [ scope('gmail.modify'), scope('gmail.send'), scope('calendar.events'), scope('calendar.calendarlist.readonly'), + scope('calendar.events.freebusy'), scope('drive'), - scope('documents'), - scope('spreadsheets'), - scope('presentations'), scope('contacts'), + scope('contacts.other.readonly'), + scope('tasks'), ]; const ACTION_SCOPES: Partial> = { @@ -48,6 +57,7 @@ const ACTION_SCOPES: Partial> = { trashEmails: GMAIL, sendEmails: [[scope('gmail.send')], [scope('gmail.modify')]], readEvents: CALENDAR, + readAvailability: AVAILABILITY, writeEvents: CALENDAR, respondEvents: CALENDAR, inviteGuests: CALENDAR, @@ -57,6 +67,9 @@ const ACTION_SCOPES: Partial> = { trashFiles: DRIVE, readContacts: CONTACTS, writeContacts: CONTACTS, + readOtherContacts: OTHER_CONTACTS, + readTasks: TASKS, + writeTasks: TASKS, }; /** The actions a Google sign-in didn't cover, in the catalog's order; one the map leaves out is never covered. */ diff --git a/packages/api/src/connections/google/tasks-api.ts b/packages/api/src/connections/google/tasks-api.ts new file mode 100644 index 00000000..ecc0bbdf --- /dev/null +++ b/packages/api/src/connections/google/tasks-api.ts @@ -0,0 +1,123 @@ +import type {Result} from 'ts-results-es'; +import {z} from 'zod'; + +import type {ApiError} from '@proxy/utils'; + +import {googleRequest, googleWrite} from './google-client'; + +/** + * The Tasks v1 calls the `tasks` collection makes, and the reading of their answers: the account's + * task lists, and the tasks in each, subtasks and those assigned from Docs and Chat among them, in + * Google's own order. + */ + +const TASKS = 'https://tasks.googleapis.com/tasks/v1'; + +/** The tasks a page of a list holds. */ +export const TASKS_PAGE_SIZE = 50; + +// Google's most, far more lists than anyone keeps. +const LISTS_SIZE = 100; + +const taskListSchema = z.object({id: z.string(), title: z.string().optional(), updated: z.string().optional()}); + +export type TaskList = z.infer; + +const taskListsSchema = z.object({items: z.array(taskListSchema).optional()}); + +const taskSchema = z.object({ + id: z.string(), + title: z.string().optional(), + notes: z.string().optional(), + // `needsAction` or `completed`. + status: z.string().optional(), + // A day at midnight UTC: Google Tasks keeps no time of day. + due: z.string().optional(), + completed: z.string().optional(), + // The task a subtask is under. + parent: z.string().optional(), + updated: z.string().optional(), + webViewLink: z.string().optional(), + // What a task was made from, such as an email in Gmail. + links: z.array(z.object({type: z.string().optional(), description: z.string().optional(), link: z.string().optional()})).optional(), + deleted: z.boolean().optional(), +}); + +export type Task = z.infer; + +const tasksSchema = z.object({items: z.array(taskSchema).optional(), nextPageToken: z.string().optional()}); + +/** What a task is written with: only what changes, on an edit; a due date of null takes it off. */ +export type TaskWrite = { + title?: string; + notes?: string; + due?: string | null; + status?: 'needsAction' | 'completed'; + completed?: null; +}; + +/** Which of a list's tasks to list: all of them, or only those still to do or done. */ +export type TaskStatus = 'open' | 'done' | null; + +function listUrl(listId: string, rest = ''): URL { + return new URL(`${TASKS}/lists/${encodeURIComponent(listId)}/tasks${rest}`); +} + +function taskUrl(listId: string, taskId: string, rest = ''): URL { + return listUrl(listId, `/${encodeURIComponent(taskId)}${rest}`); +} + +/** The account's task lists, as Google Tasks shows them. */ +export async function listTaskLists(accessToken: string): Promise> { + const url = new URL(`${TASKS}/users/@me/lists`); + url.searchParams.set('maxResults', String(LISTS_SIZE)); + const answer = await googleRequest(accessToken, url, taskListsSchema); + return answer.map(({items = []}) => items); +} + +export function getTaskList(accessToken: string, listId: string): Promise> { + return googleRequest(accessToken, new URL(`${TASKS}/users/@me/lists/${encodeURIComponent(listId)}`), taskListSchema); +} + +/** + * A page of a list's tasks, in Google's order, those assigned from Docs and Chat among them, which + * Google leaves out unless asked. Tasks completed in Google Tasks are hidden + * there, so done ones are asked for with the hidden ones; a lower bound on when a task was + * completed leaves out the ones still to do. + */ +export async function listTasks(accessToken: string, listId: string, args: {status: TaskStatus; page: string | null}): Promise> { + const url = listUrl(listId); + url.searchParams.set('maxResults', String(TASKS_PAGE_SIZE)); + url.searchParams.set('showCompleted', String(args.status !== 'open')); + url.searchParams.set('showHidden', String(args.status !== 'open')); + url.searchParams.set('showAssigned', 'true'); + if (args.status === 'done') { + url.searchParams.set('completedMin', '1970-01-01T00:00:00.000Z'); + } + if (args.page) { + url.searchParams.set('pageToken', args.page); + } + const answer = await googleRequest(accessToken, url, tasksSchema); + return answer.map(({items = [], nextPageToken}) => ({tasks: items, nextPage: nextPageToken ?? null})); +} + +export function getTask(accessToken: string, listId: string, taskId: string): Promise> { + return googleRequest(accessToken, taskUrl(listId, taskId), taskSchema); +} + +/** Makes a task at the top of a list. */ +export function insertTask(accessToken: string, listId: string, task: TaskWrite): Promise> { + return googleWrite(accessToken, 'POST', listUrl(listId), task, taskSchema); +} + +/** Changes what is given of a task, the rest as it is. */ +export function patchTask(accessToken: string, listId: string, taskId: string, task: TaskWrite): Promise> { + return googleWrite(accessToken, 'PATCH', taskUrl(listId, taskId), task, taskSchema); +} + +/** Moves a task to the top of another list; Google turns a repeating one down. */ +export function moveTask(accessToken: string, listId: string, taskId: string, destinationListId: string): Promise> { + const url = taskUrl(listId, taskId, '/move'); + url.searchParams.set('destinationTasklist', destinationListId); + return googleWrite(accessToken, 'POST', url, {}, taskSchema); +} diff --git a/packages/api/src/connections/route.test.ts b/packages/api/src/connections/route.test.ts index 1a96352f..f4b3e977 100644 --- a/packages/api/src/connections/route.test.ts +++ b/packages/api/src/connections/route.test.ts @@ -83,7 +83,10 @@ const withoutDrive = googleRow([ 'https://www.googleapis.com/auth/gmail.modify', 'https://www.googleapis.com/auth/calendar.events', 'https://www.googleapis.com/auth/calendar.calendarlist.readonly', + 'https://www.googleapis.com/auth/calendar.events.freebusy', 'https://www.googleapis.com/auth/contacts', + 'https://www.googleapis.com/auth/contacts.other.readonly', + 'https://www.googleapis.com/auth/tasks', ]); function makeRequest(params: Record = {}, body: unknown = undefined) { diff --git a/packages/api/src/connections/unavailable.test.ts b/packages/api/src/connections/unavailable.test.ts index 388866c7..86a087f9 100644 --- a/packages/api/src/connections/unavailable.test.ts +++ b/packages/api/src/connections/unavailable.test.ts @@ -22,11 +22,11 @@ const ALL_SCOPES = [ 'https://www.googleapis.com/auth/gmail.send', 'https://www.googleapis.com/auth/calendar.events', 'https://www.googleapis.com/auth/calendar.calendarlist.readonly', + 'https://www.googleapis.com/auth/calendar.events.freebusy', 'https://www.googleapis.com/auth/drive', - 'https://www.googleapis.com/auth/documents', - 'https://www.googleapis.com/auth/spreadsheets', - 'https://www.googleapis.com/auth/presentations', 'https://www.googleapis.com/auth/contacts', + 'https://www.googleapis.com/auth/contacts.other.readonly', + 'https://www.googleapis.com/auth/tasks', ]; function google(scopes: string[]) { @@ -57,17 +57,55 @@ describe('unavailableActions', () => { test('Calendar needs both its scopes, its events and the list of calendars holding them', async () => { const {unavailableActions} = await import('./unavailable'); - const calendar = ['readEvents', 'writeEvents', 'respondEvents', 'inviteGuests']; + const events = ['readEvents', 'writeEvents', 'respondEvents', 'inviteGuests']; + + expect(unavailableActions(google(ALL_SCOPES.filter((scope) => !scope.endsWith('/calendar.events'))))).toEqual(events); + expect(unavailableActions(google(ALL_SCOPES.filter((scope) => !scope.endsWith('/calendar.calendarlist.readonly'))))).toEqual([ + 'readEvents', + 'readAvailability', + 'writeEvents', + 'respondEvents', + 'inviteGuests', + ]); + }); + + test('who is free, other contacts and tasks each need a scope of their own', async () => { + const {unavailableActions} = await import('./unavailable'); + const without = (name: string) => google(ALL_SCOPES.filter((scope) => !scope.endsWith(`/${name}`))); - expect(unavailableActions(google(ALL_SCOPES.filter((scope) => !scope.endsWith('/calendar.events'))))).toEqual(calendar); - expect(unavailableActions(google(ALL_SCOPES.filter((scope) => !scope.endsWith('/calendar.calendarlist.readonly'))))).toEqual(calendar); + expect(unavailableActions(without('calendar.events.freebusy'))).toEqual(['readAvailability']); + expect(unavailableActions(without('contacts.other.readonly'))).toEqual(['readOtherContacts']); + expect(unavailableActions(without('tasks'))).toEqual(['readTasks', 'writeTasks']); }); - test('a Google sign-in from before Calendar and Contacts joined leaves their actions out', async () => { + test('a Google sign-in from before Calendar, Contacts and Tasks joined leaves their actions out', async () => { const {unavailableActions} = await import('./unavailable'); - const before = ALL_SCOPES.filter((scope) => !/\/(calendar\.|contacts)/.test(scope)); + const before = ALL_SCOPES.filter((scope) => !/\/(calendar\.|contacts|tasks)/.test(scope)); + + expect(unavailableActions(google(before))).toEqual([ + 'readEvents', + 'readAvailability', + 'writeEvents', + 'respondEvents', + 'inviteGuests', + 'readContacts', + 'readOtherContacts', + 'writeContacts', + 'readTasks', + 'writeTasks', + ]); + }); - expect(unavailableActions(google(before))).toEqual(['readEvents', 'writeEvents', 'respondEvents', 'inviteGuests', 'readContacts', 'writeContacts']); + test('a Google sign-in from before who is free, other contacts and tasks joined leaves only those out, whatever else it granted', async () => { + const {unavailableActions} = await import('./unavailable'); + const before = [ + ...ALL_SCOPES.filter((scope) => !/\/(calendar\.events\.freebusy|contacts\.other\.readonly|tasks)$/.test(scope)), + 'https://www.googleapis.com/auth/documents', + 'https://www.googleapis.com/auth/spreadsheets', + 'https://www.googleapis.com/auth/presentations', + ]; + + expect(unavailableActions(google(before))).toEqual(['readAvailability', 'readOtherContacts', 'readTasks', 'writeTasks']); }); test('every other integration covers all its actions, whatever its credential', async () => { diff --git a/packages/api/src/records/calendar-time.ts b/packages/api/src/records/calendar-time.ts new file mode 100644 index 00000000..7bb484c5 --- /dev/null +++ b/packages/api/src/records/calendar-time.ts @@ -0,0 +1,69 @@ +import {Result} from 'ts-results-es'; + +/** + * Times as agents write them for Google Calendar, and a time zone's clock: a day alone, + * `2026-10-15`, or a time, `2026-10-15T14:00`, with an offset or on a calendar's clock. + */ + +/** A time as written: a day alone, or a time, with its offset or on the calendar's clock. */ +export type Written = {kind: 'day'; date: string} | {kind: 'time'; dateTime: string; offset: boolean}; + +const DAY = /^\d{4}-\d{2}-\d{2}$/; +const TIME = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/i; + +/** A day that is on the calendar, 2026-10-15, not 2026-02-30. */ +export function realDay(day: string): boolean { + return DAY.test(day) && new Date(`${day}T00:00:00Z`).toISOString().slice(0, 10) === day; +} + +/** A day or a time as written, its offset written as Google takes it, `+02:00` or `Z`; null for anything else. */ +export function readTime(text: string): Written | null { + const trimmed = text.trim(); + if (realDay(trimmed)) { + return {kind: 'day', date: trimmed}; + } + const [, day = '', hours = '', minutes = '', seconds = '00', offset] = TIME.exec(trimmed) ?? []; + if (!realDay(day) || Number(hours) > 23 || Number(minutes) > 59 || Number(seconds) > 59) { + return null; + } + const dateTime = `${day}T${hours}:${minutes}:${seconds}`; + if (offset === undefined) { + return {kind: 'time', dateTime, offset: false}; + } + const zone = offset.toUpperCase() === 'Z' ? 'Z' : `${offset.slice(0, 3)}:${offset.slice(-2)}`; + return {kind: 'time', dateTime: `${dateTime}${zone}`, offset: true}; +} + +// A time zone's clock at an instant, as the parts Intl formats it in. +function clockParts(instant: number, timeZone: string): Record { + const parts = new Intl.DateTimeFormat('en-US', {timeZone, hourCycle: 'h23', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric'}).formatToParts( + instant, + ); + return Object.fromEntries(parts.filter((part) => part.type !== 'literal').map((part) => [part.type, Number(part.value)])); +} + +// How far ahead of UTC a time zone's clock is at an instant. +function offsetMs(instant: number, timeZone: string): number { + const part = clockParts(instant, timeZone); + const clock = Date.UTC(part.year ?? 1970, (part.month ?? 1) - 1, part.day ?? 1, part.hour ?? 0, part.minute ?? 0, part.second ?? 0); + return clock - Math.floor(instant / 1000) * 1000; +} + +/** The instant a time on a time zone's clock is, `2026-10-15T09:00:00`, UTC's where the zone is none Intl knows. */ +export function zonedInstant(clock: string, timeZone: string | undefined): string { + const asUtc = Date.parse(`${clock}Z`); + const shifted = Result.wrap(() => { + const zone = timeZone ?? 'UTC'; + const guess = asUtc - offsetMs(asUtc, zone); + return asUtc - offsetMs(guess, zone); + }); + return new Date(shifted.unwrapOr(asUtc)).toISOString(); +} + +const pad = (n: number | undefined) => String(n ?? 0).padStart(2, '0'); + +/** An instant as a time zone's clock shows it, `2026-10-15 09:00`, UTC's where the zone is none Intl knows. */ +export function clockTime(instant: number, timeZone: string): string { + const part = Result.wrap(() => clockParts(instant, timeZone)).unwrapOr(clockParts(instant, 'UTC')); + return `${part.year}-${pad(part.month)}-${pad(part.day)} ${pad(part.hour)}:${pad(part.minute)}`; +} diff --git a/packages/api/src/records/google-availability.test.ts b/packages/api/src/records/google-availability.test.ts new file mode 100644 index 00000000..fc467ad3 --- /dev/null +++ b/packages/api/src/records/google-availability.test.ts @@ -0,0 +1,187 @@ +import {beforeEach, describe, expect, mock, spyOn, test} from 'bun:test'; +import {Ok, type Result} from 'ts-results-es'; + +import type {ApiError} from '@proxy/utils'; +import {findCollection, findIntegration, type Collection} from '@proxy/integrations'; + +mock.module('@proxy/db/connection', () => ({updateConnectionCredential: mock(), getConnectionCredential: mock(async () => Ok(null))})); + +mock.module('../observability/log', () => ({ + log: {debug: mock(), info: mock(), warn: mock(), error: mock()}, + serializeError: mock(() => ({})), +})); + +mock.module('../utils/env', () => ({env: {NODE_ENV: 'production', APP_URL: 'https://app.example.com', GOOGLE_CLIENT_ID: 'client-1', GOOGLE_CLIENT_SECRET: 'secret-1'}})); + +mock.module('../utils/secret-crypto', () => ({ + encryptSecret: (plaintext: string): Result => Ok(`enc:${plaintext}`), + decryptSecret: (stored: string): Result => Ok(stored.replace(/^enc:/, '')), +})); + +function availability(): Collection { + const integration = findIntegration('google'); + const found = integration && findCollection(integration, 'availability'); + if (!found) { + throw new Error('No availability collection'); + } + return found; +} + +// What a refusal says, or its kind where it says nothing of its own. +function messageOf(result: Result | undefined): string | null { + if (!result || result.isOk()) { + return null; + } + return 'message' in result.error ? result.error.message : result.error.kind; +} + +const CREDENTIAL = {accessToken: 'access-1', refreshToken: 'refresh-1', expiresAt: new Date(Date.now() + 3_600_000).toISOString(), scopes: [], email: 'alex@example.com'}; + +const target = { + connection: { + id: 'google-1', + integrationId: 'google', + orgId: 'org-1', + ownerUserId: 'user-1', + connectedById: 'user-1', + account: 'alex@example.com', + createdAt: new Date(), + tools: null, + name: null, + defaults: [], + credential: `enc:${JSON.stringify(CREDENTIAL)}`, + }, + collection: availability(), +}; + +const PRIMARY = {id: 'alex@example.com', summary: 'alex@example.com', primary: true, timeZone: 'Europe/Berlin'}; + +// Alex has a standup and lunch; Dana has a long meeting overlapping lunch; Sam's calendar can't be seen. +const FREE_BUSY = { + calendars: { + primary: { + busy: [ + {start: '2026-10-15T07:00:00Z', end: '2026-10-15T07:30:00Z'}, + {start: '2026-10-15T10:00:00Z', end: '2026-10-15T11:00:00Z'}, + ], + }, + 'dana@example.com': {busy: [{start: '2026-10-15T10:30:00Z', end: '2026-10-15T12:00:00Z'}]}, + 'sam@example.com': {errors: [{domain: 'global', reason: 'notFound'}], busy: []}, + }, +}; + +type Request = {method: string; url: URL; body: string}; + +const google = {requests: [] as Request[], answer: (_request: Request): Response | null => null}; + +spyOn(globalThis, 'fetch').mockImplementation((async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url); + const request = {method: init?.method ?? 'GET', url, body: typeof init?.body === 'string' ? init.body : ''}; + google.requests.push(request); + const custom = google.answer(request); + if (custom) { + return custom; + } + if (url.pathname === '/calendar/v3/freeBusy') { + return Response.json(FREE_BUSY); + } + return Response.json(PRIMARY); +}) as typeof fetch); + +beforeEach(() => { + google.requests = []; + google.answer = () => null; +}); + +function freeBusyBody(): {timeMin: string; timeMax: string; items: Array<{id: string}>} { + const request = google.requests.find((one) => one.url.pathname === '/calendar/v3/freeBusy'); + return JSON.parse(request?.body ?? '{}'); +} + +describe('checking availability', () => { + test('asks Google when the account and the people named are busy, on the primary calendar’s clock', async () => { + const {googleConnector} = await import('./google-connector'); + const record = (await googleConnector.newCommands?.check?.(target, {people: 'Dana Fox , sam@example.com', from: '2026-10-15T08:00', to: '2026-10-15T18:00'}))?.unwrap(); + + expect(google.requests[0]?.url.pathname).toBe('/calendar/v3/users/me/calendarList/primary'); + expect(freeBusyBody()).toEqual({ + timeMin: '2026-10-15T06:00:00.000Z', + timeMax: '2026-10-15T16:00:00.000Z', + items: [{id: 'primary'}, {id: 'dana@example.com'}, {id: 'sam@example.com'}], + }); + expect(record?.values).toEqual({ + people: 'dana@example.com, sam@example.com', + from: '2026-10-15 08:00', + to: '2026-10-15 18:00', + timeZone: 'Europe/Berlin', + busy: ['You (alex@example.com): 2026-10-15 09:00–09:30, 2026-10-15 12:00–13:00', 'dana@example.com: 2026-10-15 12:30–14:00', 'sam@example.com: can’t see this calendar'].join('\n'), + free: ['2026-10-15 08:00–09:00', '2026-10-15 09:30–12:00', '2026-10-15 14:00–18:00', 'Not counting sam@example.com, whose calendar can’t be seen'].join('\n'), + }); + }); + + test('a day alone is the whole day, and times with an offset are taken as they are', async () => { + const {googleConnector} = await import('./google-connector'); + await googleConnector.newCommands?.check?.(target, {people: '', from: '2026-10-15', to: '2026-10-16'}); + const days = freeBusyBody(); + google.requests = []; + await googleConnector.newCommands?.check?.(target, {people: '', from: '2026-10-15T09:00:00Z', to: '2026-10-15T17:00:00+01:00'}); + + expect([days.timeMin, days.timeMax]).toEqual(['2026-10-14T22:00:00.000Z', '2026-10-16T22:00:00.000Z']); + expect([freeBusyBody().timeMin, freeBusyBody().timeMax]).toEqual(['2026-10-15T09:00:00.000Z', '2026-10-15T16:00:00.000Z']); + }); + + test('left out, it checks the account alone, from now for a week', async () => { + const {googleConnector} = await import('./google-connector'); + const before = Date.now(); + await googleConnector.newCommands?.check?.(target, {people: '', from: '', to: ''}); + const body = freeBusyBody(); + + expect(body.items).toEqual([{id: 'primary'}]); + expect(Date.parse(body.timeMin)).toBeGreaterThanOrEqual(before - 1000); + expect(Date.parse(body.timeMax) - Date.parse(body.timeMin)).toBe(7 * 24 * 3_600_000); + }); + + test('busy times running past the window are cut to it, and someone with nothing on is free', async () => { + const {googleConnector} = await import('./google-connector'); + google.answer = (request) => + request.url.pathname === '/calendar/v3/freeBusy' + ? Response.json({calendars: {primary: {busy: [{start: '2026-10-14T20:00:00Z', end: '2026-10-15T07:00:00Z'}]}, 'dana@example.com': {busy: []}}}) + : null; + const record = (await googleConnector.newCommands?.check?.(target, {people: 'dana@example.com', from: '2026-10-15T08:00', to: '2026-10-15T12:00'}))?.unwrap(); + + expect(record?.values.busy).toBe('You (alex@example.com): 2026-10-15 08:00–09:00\ndana@example.com: free'); + expect(record?.values.free).toBe('2026-10-15 09:00–12:00'); + }); + + test('the account named among the people is checked once', async () => { + const {googleConnector} = await import('./google-connector'); + await googleConnector.newCommands?.check?.(target, {people: 'ALEX@example.com, dana@example.com', from: '2026-10-15', to: ''}); + + expect(freeBusyBody().items).toEqual([{id: 'primary'}, {id: 'dana@example.com'}]); + }); + + test('people that aren’t addresses, times that aren’t times, and a to before from never reach free/busy', async () => { + const {googleConnector} = await import('./google-connector'); + const refusals = await Promise.all([ + googleConnector.newCommands?.check?.(target, {people: 'Dana', from: '', to: ''}), + googleConnector.newCommands?.check?.(target, {people: '', from: 'tomorrow', to: ''}), + googleConnector.newCommands?.check?.(target, {people: '', from: '2026-10-16', to: '2026-10-15T12:00'}), + googleConnector.newCommands?.check?.(target, {people: Array.from({length: 50}, (_, index) => `p${index}@example.com`).join(','), from: '', to: ''}), + ]); + + expect(refusals.map(messageOf)).toEqual([ + 'Write each person as their email address, not “Dana”', + 'Write from as a time, 2026-10-15T09:00, on your calendar’s clock or with an offset such as +02:00, or as a day, 2026-10-15, not “tomorrow”', + 'Write a to that is after from', + 'Google checks 49 people at most at once, beside you', + ]); + expect(google.requests.filter((request) => request.url.pathname === '/calendar/v3/freeBusy')).toEqual([]); + }); + + test('availability is never listed or opened', async () => { + const {googleConnector} = await import('./google-connector'); + const result = await googleConnector.list(target, {search: null, page: null, filter: null, parent: null}); + + expect(result.isErr() && result.error.kind).toBe('validation_error'); + }); +}); diff --git a/packages/api/src/records/google-availability.ts b/packages/api/src/records/google-availability.ts new file mode 100644 index 00000000..c11fc807 --- /dev/null +++ b/packages/api/src/records/google-availability.ts @@ -0,0 +1,180 @@ +import {Err, Ok, type Result} from 'ts-results-es'; + +import {ApiErr, type ApiError, Do} from '@proxy/utils'; +import {addDays} from '@proxy/utils/days'; + +import {getCalendar, queryFreeBusy, type FreeBusy} from '../connections/google/calendar-api'; +import {callGoogle} from '../connections/google/google-client'; +import {clockTime, readTime, zonedInstant} from './calendar-time'; +import {onlyRun, type Connector, type NewCommandRunner} from './connector'; + +/** + * When people are free, as the `availability` collection: never listed or opened, only checked. + * Google's free/busy is asked of the account's primary calendar and those of the people named, + * from From to To, from now and for a week where they are left out. A time without an offset is + * on the primary calendar's clock and a day alone is the whole day, and every time answered is on + * that clock. Someone whose calendar the account can't see is said to be so, never free, and left + * out of the times everyone is free, which the answer says. + */ + +const DAY_MS = 24 * 3_600_000; +const WEEK_MS = 7 * DAY_MS; + +// Google answers for 50 calendars at most; the account's own is one of them. +const MAX_PEOPLE = 49; + +// The account's own calendar, as Google takes it and answers for it. +const PRIMARY = 'primary'; + +const ADDRESS = /^[^\s@<>(),;"']+@[^\s@<>(),;"']+\.[^\s@<>(),;"']+$/; + +// The people written, by their addresses, comma-separated; `Sam Lee ` is taken too. +function parsePeople(text: string, account: string): Result { + const addresses: string[] = []; + for (const part of text + .split(/[,;\n]/) + .map((one) => one.trim()) + .filter(Boolean)) { + const address = (/<([^>]+)>/.exec(part)?.[1] ?? part).trim().toLowerCase(); + if (!ADDRESS.test(address)) { + return Err(ApiErr.validationError(`Write each person as their email address, not “${part}”`)); + } + if (address !== account.toLowerCase() && !addresses.includes(address)) { + addresses.push(address); + } + } + if (addresses.length > MAX_PEOPLE) { + return Err(ApiErr.validationError(`Google checks ${MAX_PEOPLE} people at most at once, beside you`)); + } + return Ok(addresses); +} + +// An instant written as From or To: a day's start, or the next day's for To, a time with its +// offset, or one on the calendar's clock. Null where it was left out. +function parseBoundary(label: string, text: string, timeZone: string, end: boolean): Result { + const trimmed = text.trim(); + if (trimmed === '') { + return Ok(null); + } + const time = readTime(trimmed); + if (!time) { + return Err(ApiErr.validationError(`Write ${label} as a time, 2026-10-15T09:00, on your calendar’s clock or with an offset such as +02:00, or as a day, 2026-10-15, not “${trimmed}”`)); + } + if (time.kind === 'day') { + return Ok(Date.parse(zonedInstant(`${end ? addDays(time.date, 1) : time.date}T00:00:00`, timeZone))); + } + if (time.offset) { + return Ok(Date.parse(time.dateTime)); + } + return Ok(Date.parse(zonedInstant(time.dateTime, timeZone))); +} + +type Span = {start: number; end: number}; + +// Spans in order, those that overlap or touch made one. +function merged(spans: Span[]): Span[] { + const sorted = [...spans].sort((a, b) => a.start - b.start); + return sorted.reduce((kept, span) => { + const last = kept.at(-1); + if (last && span.start <= last.end) { + last.end = Math.max(last.end, span.end); + return kept; + } + return [...kept, {...span}]; + }, []); +} + +// "2026-10-15 09:00–10:00", or "2026-10-15 22:00 – 2026-10-16 01:00" across midnight. +function spanText(span: Span, timeZone: string): string { + const start = clockTime(span.start, timeZone); + const end = clockTime(span.end, timeZone); + if (start.slice(0, 10) === end.slice(0, 10)) { + return `${start}–${end.slice(11)}`; + } + return `${start} – ${end}`; +} + +// The gaps between busy spans within the window. +function gaps(window: Span, busy: Span[]): Span[] { + const free: Span[] = []; + let from = window.start; + for (const span of merged(busy)) { + if (span.start > from) { + free.push({start: from, end: Math.min(span.start, window.end)}); + } + from = Math.max(from, span.end); + } + if (from < window.end) { + free.push({start: from, end: window.end}); + } + return free.filter((span) => span.end > span.start); +} + +type Answer = {who: string; busy: Span[] | null}; + +// A calendar's busy spans within the window, by the first of its ids Google answered for, or null +// where Google couldn't say, as for a calendar the account can't see. +function answerFor(freeBusy: FreeBusy, calendars: string[], window: Span): Span[] | null { + const answer = calendars.map((calendar) => freeBusy.calendars?.[calendar]).find(Boolean); + if (!answer || (answer.errors ?? []).length > 0) { + return null; + } + return (answer.busy ?? []).map((span) => ({start: Math.max(Date.parse(span.start), window.start), end: Math.min(Date.parse(span.end), window.end)})).filter((span) => span.end > span.start); +} + +function busyLine(answer: Answer, timeZone: string): string { + if (answer.busy === null) { + return `${answer.who}: can’t see this calendar`; + } + if (answer.busy.length === 0) { + return `${answer.who}: free`; + } + return `${answer.who}: ${merged(answer.busy) + .map((span) => spanText(span, timeZone)) + .join(', ')}`; +} + +function freeText(answers: Answer[], window: Span, timeZone: string): string { + const seen = answers.flatMap((answer) => (answer.busy === null ? [] : [answer.busy])); + const free = gaps(window, seen.flat()); + const lines = free.length > 0 ? free.map((span) => spanText(span, timeZone)) : ['No time when everyone is free']; + const unseen = answers.filter((answer) => answer.busy === null).map((answer) => answer.who); + if (unseen.length > 0) { + lines.push(`Not counting ${unseen.join(', ')}, whose calendar can’t be seen`); + } + return lines.join('\n'); +} + +/** When the account and the people named are busy, and when all of them are free. */ +const check: NewCommandRunner = (target, values) => + callGoogle(target.connection, (token) => + Do(async ($) => { + const account = target.connection.account; + const people = $(parsePeople(values.people ?? '', account)); + const timeZone = $(await getCalendar(token, PRIMARY)).timeZone ?? 'UTC'; + const start = $(parseBoundary('from', values.from ?? '', timeZone, false)) ?? Date.now(); + const end = $(parseBoundary('to', values.to ?? '', timeZone, true)) ?? start + WEEK_MS; + if (end <= start) { + return $(Err(ApiErr.validationError('Write a to that is after from'))); + } + const window = {start, end}; + const freeBusy = $(await queryFreeBusy(token, {from: new Date(start).toISOString(), to: new Date(end).toISOString(), calendars: [PRIMARY, ...people]})); + // Google answers for the account's own calendar as it was asked for, or by its address. + const own = {who: `You (${account})`, busy: answerFor(freeBusy, [PRIMARY, account.toLowerCase()], window)}; + const answers: Answer[] = [own, ...people.map((person) => ({who: person, busy: answerFor(freeBusy, [person], window)}))]; + return { + id: 'availability', + values: { + people: people.join(', '), + from: clockTime(start, timeZone), + to: clockTime(end, timeZone), + timeZone, + busy: answers.map((answer) => busyLine(answer, timeZone)).join('\n'), + free: freeText(answers, window, timeZone), + }, + updatedAt: new Date().toISOString(), + }; + }), + ); + +export const availabilityConnector: Connector = {...onlyRun('Availability is only checked, for the people and times given'), newCommands: {check}}; diff --git a/packages/api/src/records/google-connector.ts b/packages/api/src/records/google-connector.ts index 309d13e8..63049a17 100644 --- a/packages/api/src/records/google-connector.ts +++ b/packages/api/src/records/google-connector.ts @@ -3,18 +3,24 @@ import {Err, type Result} from 'ts-results-es'; import {ApiErr, type ApiError} from '@proxy/utils'; import type {CommandRunner, Connector, NewCommandRunner, RecordTarget} from './connector'; +import {availabilityConnector} from './google-availability'; import {contactsConnector} from './google-contacts'; import {gmailConnector} from './google-emails'; import {eventsConnector} from './google-events'; import {createFilesRecord, getFilesRecord, listFilesTree, trashFilesRecord, updateFilesRecord} from './google-files'; +import {otherContactsConnector} from './google-other-contacts'; import {rangesConnector} from './google-ranges'; +import {tasksConnector} from './google-tasks'; /** * A Google account, fetched live from Google's APIs with the connection's tokens, one collection * per product: `emails`, Gmail (`google-emails.ts`), `events`, Calendar (`google-events.ts`), - * `files`, Drive with Docs, Sheets and Slides (`google-files.ts`), `ranges`, a spreadsheet's values - * in A1 notation (`google-ranges.ts`), and `contacts`, Google Contacts (`google-contacts.ts`). - * Commands go to the collection they were run on, since two can share an id, as Trash does. + * `availability`, when people are busy (`google-availability.ts`), `files`, Drive with Docs, + * Sheets and Slides (`google-files.ts`), `ranges`, a spreadsheet's values in A1 notation + * (`google-ranges.ts`), `contacts`, Google Contacts (`google-contacts.ts`), `otherContacts`, the + * people Gmail keeps under Other contacts (`google-other-contacts.ts`), and `tasks`, Google Tasks + * (`google-tasks.ts`). Commands go to the collection they were run on, since two can share an id, + * as Trash does. */ // Files are moved to Trash, never deleted: the collection offers no delete. @@ -31,7 +37,16 @@ const filesConnector: Connector = { commands: {trash: trashFilesRecord}, }; -const COLLECTIONS: Partial> = {emails: gmailConnector, events: eventsConnector, files: filesConnector, ranges: rangesConnector, contacts: contactsConnector}; +const COLLECTIONS: Partial> = { + emails: gmailConnector, + events: eventsConnector, + availability: availabilityConnector, + files: filesConnector, + ranges: rangesConnector, + contacts: contactsConnector, + otherContacts: otherContactsConnector, + tasks: tasksConnector, +}; function unknownCollection(target: RecordTarget): Promise> { return Promise.resolve(Err(ApiErr.notFound('collection', target.collection.id))); diff --git a/packages/api/src/records/google-contacts.ts b/packages/api/src/records/google-contacts.ts index ebfc8538..347662b4 100644 --- a/packages/api/src/records/google-contacts.ts +++ b/packages/api/src/records/google-contacts.ts @@ -54,7 +54,7 @@ function birthdayOf(person: Person): string { return birthday?.text ?? ''; } -function contactValues(person: Person): RecordValues { +export function contactValues(person: Person): RecordValues { const organization = person.organizations?.[0]; const name = person.names?.[0]; return { diff --git a/packages/api/src/records/google-events.ts b/packages/api/src/records/google-events.ts index aff695b8..9f5f40fc 100644 --- a/packages/api/src/records/google-events.ts +++ b/packages/api/src/records/google-events.ts @@ -1,4 +1,4 @@ -import {Err, Ok, Result} from 'ts-results-es'; +import {Err, Ok, type Result} from 'ts-results-es'; import {z} from 'zod'; import {ApiErr, type ApiError, Do} from '@proxy/utils'; @@ -18,6 +18,7 @@ import { type GoogleEvent, } from '../connections/google/calendar-api'; import {callGoogle} from '../connections/google/google-client'; +import {readTime, realDay, zonedInstant, type Written} from './calendar-time'; import {requireFirstPage, type CommandRunner, type Connector, type DataRecord, type ListQuery, type RecordPage, type RecordTarget, type RecordValues} from './connector'; import {readPageToken, writePageToken} from './page-token'; @@ -186,34 +187,16 @@ function eventRecord(calendarId: string, event: GoogleEvent): DataRecord { return {id: eventRecordId(calendarId, event.id), values: eventValues(event), updatedAt: event.updated ?? NEVER, ...(event.htmlLink ? {url: event.htmlLink} : {})}; } -// A time as written: a day for an all-day event, or a time, with its offset or on the calendar's clock. -type Written = {kind: 'day'; date: string} | {kind: 'time'; dateTime: string; offset: boolean}; - -const DAY = /^\d{4}-\d{2}-\d{2}$/; -const TIME = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/i; - -function realDay(day: string): boolean { - return DAY.test(day) && new Date(`${day}T00:00:00Z`).toISOString().slice(0, 10) === day; -} - function parseTime(label: string, text: string): Result { - const trimmed = text.trim(); - const refusal = ApiErr.validationError( - `Write ${label} as a time, 2026-10-15T14:00, on the calendar’s clock or with an offset such as +02:00, or as a day, 2026-10-15, for an all-day event, not “${trimmed}”`, - ); - if (realDay(trimmed)) { - return Ok({kind: 'day', date: trimmed}); - } - const [, day = '', hours = '', minutes = '', seconds = '00', offset] = TIME.exec(trimmed) ?? []; - if (!realDay(day) || Number(hours) > 23 || Number(minutes) > 59 || Number(seconds) > 59) { - return Err(refusal); - } - const dateTime = `${day}T${hours}:${minutes}:${seconds}`; - if (offset === undefined) { - return Ok({kind: 'time', dateTime, offset: false}); + const time = readTime(text); + if (!time) { + return Err( + ApiErr.validationError( + `Write ${label} as a time, 2026-10-15T14:00, on the calendar’s clock or with an offset such as +02:00, or as a day, 2026-10-15, for an all-day event, not “${text.trim()}”`, + ), + ); } - const zone = offset.toUpperCase() === 'Z' ? 'Z' : `${offset.slice(0, 3)}:${offset.slice(-2)}`; - return Ok({kind: 'time', dateTime: `${dateTime}${zone}`, offset: true}); + return Ok(time); } const HOUR_MS = 3_600_000; @@ -333,25 +316,9 @@ function readPage(page: string | null): Result | null return readPageToken(pageSchema, page); } -// How far ahead of UTC a time zone's clock is at an instant. -function offsetMs(instant: number, timeZone: string): number { - const parts = new Intl.DateTimeFormat('en-US', {timeZone, hourCycle: 'h23', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric'}).formatToParts( - instant, - ); - const part = (type: string) => Number(parts.find((one) => one.type === type)?.value ?? 0); - const clock = Date.UTC(part('year'), part('month') - 1, part('day'), part('hour'), part('minute'), part('second')); - return clock - Math.floor(instant / 1000) * 1000; -} - // The instant a day begins in a time zone, UTC's where the calendar has none Intl knows. function dayStart(day: string, timeZone: string | undefined): string { - const midnight = Date.parse(`${day}T00:00:00Z`); - const shifted = Result.wrap(() => { - const zone = timeZone ?? 'UTC'; - const guess = midnight - offsetMs(midnight, zone); - return midnight - offsetMs(guess, zone); - }); - return new Date(shifted.unwrapOr(midnight)).toISOString(); + return zonedInstant(`${day}T00:00:00`, timeZone); } // A search that begins with a day lists from that day on, its words searched for after it. diff --git a/packages/api/src/records/google-other-contacts.test.ts b/packages/api/src/records/google-other-contacts.test.ts new file mode 100644 index 00000000..a54fb1f1 --- /dev/null +++ b/packages/api/src/records/google-other-contacts.test.ts @@ -0,0 +1,166 @@ +import {beforeEach, describe, expect, mock, spyOn, test} from 'bun:test'; +import {Ok, type Result} from 'ts-results-es'; + +import type {ApiError} from '@proxy/utils'; +import {findCollection, findIntegration, type Collection} from '@proxy/integrations'; + +mock.module('@proxy/db/connection', () => ({updateConnectionCredential: mock(), getConnectionCredential: mock(async () => Ok(null))})); + +mock.module('../observability/log', () => ({ + log: {debug: mock(), info: mock(), warn: mock(), error: mock()}, + serializeError: mock(() => ({})), +})); + +mock.module('../utils/env', () => ({env: {NODE_ENV: 'production', APP_URL: 'https://app.example.com', GOOGLE_CLIENT_ID: 'client-1', GOOGLE_CLIENT_SECRET: 'secret-1'}})); + +mock.module('../utils/secret-crypto', () => ({ + encryptSecret: (plaintext: string): Result => Ok(`enc:${plaintext}`), + decryptSecret: (stored: string): Result => Ok(stored.replace(/^enc:/, '')), +})); + +function otherContacts(): Collection { + const integration = findIntegration('google'); + const found = integration && findCollection(integration, 'otherContacts'); + if (!found) { + throw new Error('No otherContacts collection'); + } + return found; +} + +const CREDENTIAL = {accessToken: 'access-1', refreshToken: 'refresh-1', expiresAt: new Date(Date.now() + 3_600_000).toISOString(), scopes: [], email: 'alex@example.com'}; + +const target = { + connection: { + id: 'google-1', + integrationId: 'google', + orgId: 'org-1', + ownerUserId: 'user-1', + connectedById: 'user-1', + account: 'alex@example.com', + createdAt: new Date(), + tools: null, + name: null, + defaults: [], + credential: `enc:${JSON.stringify(CREDENTIAL)}`, + }, + collection: otherContacts(), +}; + +// Someone Alex emailed, whom Gmail keeps with a name, and someone it keeps by the address alone. +const DANA = { + resourceName: 'otherContacts/c222', + metadata: {sources: [{type: 'OTHER_CONTACT', id: '222', updateTime: '2026-10-05T09:00:00Z'}]}, + names: [{displayName: 'Dana Fox', unstructuredName: 'Dana Fox'}], + emailAddresses: [{value: 'dana@example.com'}, {value: 'dana@acme.com'}], + phoneNumbers: [{value: '+1 555 0100'}], +}; + +const UNNAMED = {resourceName: 'otherContacts/c333', emailAddresses: [{value: 'billing@example.com'}]}; + +type Request = {method: string; url: URL; body: string}; + +const google = {requests: [] as Request[], answer: (_request: Request): Response | null => null}; + +spyOn(globalThis, 'fetch').mockImplementation((async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url); + const request = {method: init?.method ?? 'GET', url, body: typeof init?.body === 'string' ? init.body : ''}; + google.requests.push(request); + const custom = google.answer(request); + if (custom) { + return custom; + } + if (url.pathname === '/v1/otherContacts:search') { + return Response.json(url.searchParams.get('query') === '' ? {} : {results: [{person: DANA}]}); + } + if (url.pathname.endsWith(':copyOtherContactToMyContactsGroup')) { + return Response.json({resourceName: 'people/c999', names: [{displayName: 'Dana Fox'}]}); + } + return Response.json({otherContacts: [DANA, UNNAMED], nextPageToken: 'google-page-2'}); +}) as typeof fetch); + +beforeEach(() => { + google.requests = []; + google.answer = () => null; +}); + +describe('listing other contacts', () => { + test('a page of Google’s list, each by its name, addresses and numbers', async () => { + const {googleConnector} = await import('./google-connector'); + const page = (await googleConnector.list(target, {search: null, page: null, filter: null, parent: null})).unwrap(); + + const [request] = google.requests; + expect(request?.url.pathname).toBe('/v1/otherContacts'); + expect(Object.fromEntries(request?.url.searchParams ?? [])).toEqual({readMask: 'names,emailAddresses,phoneNumbers,metadata', pageSize: '50'}); + expect(page).toEqual({ + records: [ + {id: 'c222', values: {name: 'Dana Fox', emails: 'dana@example.com, dana@acme.com', phones: '+1 555 0100'}, updatedAt: '2026-10-05T09:00:00Z'}, + {id: 'c333', values: {name: '', emails: 'billing@example.com', phones: ''}, updatedAt: new Date(0).toISOString()}, + ], + nextPage: 'google-page-2', + }); + }); + + test('the next page goes on from Google’s token', async () => { + const {googleConnector} = await import('./google-connector'); + await googleConnector.list(target, {search: null, page: 'google-page-2', filter: null, parent: null}); + + expect(google.requests[0]?.url.searchParams.get('pageToken')).toBe('google-page-2'); + }); + + test('a search is Google’s, after the empty one Google asks for first', async () => { + const {googleConnector} = await import('./google-connector'); + const page = (await googleConnector.list(target, {search: 'dan', page: null, filter: null, parent: null})).unwrap(); + + expect(google.requests.map((request) => request.url.searchParams.get('query'))).toEqual(['', 'dan']); + expect(google.requests[1]?.url.searchParams.get('pageSize')).toBe('30'); + expect(page.records.map((record) => record.id)).toEqual(['c222']); + expect(page.nextPage).toBeNull(); + }); +}); + +describe('opening an other contact', () => { + test('it is found in Google’s list, a page of 1000 at a time', async () => { + const {googleConnector} = await import('./google-connector'); + google.answer = (request) => + request.url.searchParams.get('pageToken') === null ? Response.json({otherContacts: [UNNAMED], nextPageToken: 'google-page-2'}) : Response.json({otherContacts: [DANA]}); + const record = (await googleConnector.get(target, 'c222')).unwrap(); + + expect(google.requests.map((request) => [request.url.searchParams.get('pageSize'), request.url.searchParams.get('pageToken')])).toEqual([ + ['1000', null], + ['1000', 'google-page-2'], + ]); + expect(record.values.name).toBe('Dana Fox'); + }); + + test('one not in the list, or an id that isn’t Google’s, is not found', async () => { + const {googleConnector} = await import('./google-connector'); + google.answer = () => Response.json({otherContacts: [UNNAMED]}); + const missing = await googleConnector.get(target, 'c404'); + const wrong = await googleConnector.get(target, '../people/c222'); + + expect(missing.isErr() && missing.error.kind).toBe('not_found'); + expect(wrong.isErr() && wrong.error.kind).toBe('not_found'); + expect(google.requests).toHaveLength(1); + }); +}); + +describe('saving an other contact', () => { + test('copies its names, addresses and numbers into Google Contacts', async () => { + const {googleConnector} = await import('./google-connector'); + const result = (await googleConnector.commands?.save?.(target, 'c222'))?.unwrap(); + + const [request] = google.requests; + expect(request?.method).toBe('POST'); + expect(request?.url.pathname).toBe('/v1/otherContacts/c222:copyOtherContactToMyContactsGroup'); + expect(JSON.parse(request?.body ?? '').copyMask).toBe('names,emailAddresses,phoneNumbers'); + expect(result).toBeNull(); + }); + + test('other contacts are never made, edited or deleted here', async () => { + const {googleConnector} = await import('./google-connector'); + const results = await Promise.all([googleConnector.create(target, {name: 'Dana'}, null), googleConnector.update(target, 'c222', {name: 'Dana'}), googleConnector.remove(target, 'c222')]); + + expect(results.map((result) => result.isErr() && result.error.kind)).toEqual(['validation_error', 'validation_error', 'validation_error']); + expect(google.requests).toEqual([]); + }); +}); diff --git a/packages/api/src/records/google-other-contacts.ts b/packages/api/src/records/google-other-contacts.ts new file mode 100644 index 00000000..de2f1f07 --- /dev/null +++ b/packages/api/src/records/google-other-contacts.ts @@ -0,0 +1,95 @@ +import {Err, Ok, type Result} from 'ts-results-es'; + +import {ApiErr, type ApiError, Do} from '@proxy/utils'; + +import {callGoogle} from '../connections/google/google-client'; +import {copyOtherContact, listOtherContacts, OTHER_CONTACTS_MAX_PAGE, searchOtherContacts, type Person} from '../connections/google/people-api'; +import {contactValues} from './google-contacts'; +import {requireFirstPage, type CommandRunner, type Connector, type DataRecord, type ListQuery, type RecordPage, type RecordTarget} from './connector'; + +/** + * The people Gmail keeps under Other contacts, as the `otherContacts` collection, fetched live: + * those the account emailed or was emailed by but never saved, 50 to a page in Google's own order, + * or the 30 Google's search finds first. Google keeps only their names, addresses and numbers, and + * they are only read. A record's id is Google's, `c123…`, its resource name without + * `otherContacts/`. Saving one copies it into Google Contacts as a contact of its own. + */ + +// An other contact's resource name without `otherContacts/`, as Google makes them. +const OTHER_ID = /^c[0-9A-Za-z]{1,64}$/; + +// Google opens no other contact on its own, so one is found in its list; the pages read at most, +// of 1000 each. +const MAX_PAGES = 20; + +const NEVER = new Date(0).toISOString(); + +function requireOther(recordId: string): Result { + if (!OTHER_ID.test(recordId)) { + return Err(ApiErr.notFound('record', recordId)); + } + return Ok(`otherContacts/${recordId}`); +} + +function otherRecord(person: Person): DataRecord { + const {name = '', emails = '', phones = ''} = contactValues(person); + const updatedAt = person.metadata?.sources?.find((source) => source.updateTime)?.updateTime ?? NEVER; + return {id: person.resourceName.replace(/^otherContacts\//, ''), values: {name, emails, phones}, updatedAt}; +} + +function listOthers(target: RecordTarget, query: ListQuery): Promise> { + return callGoogle(target.connection, (token) => + Do(async ($) => { + if (query.search !== null) { + $(requireFirstPage(query)); + return {records: $(await searchOtherContacts(token, query.search)).map(otherRecord), nextPage: null}; + } + const page = $(await listOtherContacts(token, query.page)); + return {records: page.people.map(otherRecord), nextPage: page.nextPage}; + }), + ); +} + +// Looks for the other contact page by page of Google's list. +async function findOther(token: string, resourceName: string, recordId: string): Promise> { + let page: string | null = null; + for (let read = 0; read < MAX_PAGES; read++) { + const answer = await listOtherContacts(token, page, OTHER_CONTACTS_MAX_PAGE); + if (answer.isErr()) { + return answer; + } + const found = answer.value.people.find((person) => person.resourceName === resourceName); + if (found) { + return Ok(found); + } + if (answer.value.nextPage === null) { + break; + } + page = answer.value.nextPage; + } + return Err(ApiErr.notFound('record', recordId)); +} + +function getOther(target: RecordTarget, recordId: string): Promise> { + return callGoogle(target.connection, (token) => + Do(async ($) => { + return otherRecord($(await findOther(token, $(requireOther(recordId)), recordId))); + }), + ); +} + +/** Copies the other contact into Google Contacts, where it is a contact of its own from then on. */ +const save: CommandRunner = (target, recordId) => + callGoogle(target.connection, (token) => + Do(async ($) => { + const resourceName = $(requireOther(recordId)); + $((await copyOtherContact(token, resourceName)).mapErr((error) => (error.kind === 'not_found' ? ApiErr.notFound('record', recordId) : error))); + return null; + }), + ); + +function onlyRead(): Promise> { + return Promise.resolve(Err(ApiErr.validationError('Other contacts are only read; save one to contacts to edit it there'))); +} + +export const otherContactsConnector: Connector = {list: listOthers, get: getOther, create: onlyRead, update: onlyRead, remove: onlyRead, commands: {save}}; diff --git a/packages/api/src/records/google-tasks.test.ts b/packages/api/src/records/google-tasks.test.ts new file mode 100644 index 00000000..241211ac --- /dev/null +++ b/packages/api/src/records/google-tasks.test.ts @@ -0,0 +1,296 @@ +import {beforeEach, describe, expect, mock, spyOn, test} from 'bun:test'; +import {Ok, type Result} from 'ts-results-es'; + +import type {ApiError} from '@proxy/utils'; +import {findCollection, findIntegration, type Collection} from '@proxy/integrations'; + +mock.module('@proxy/db/connection', () => ({updateConnectionCredential: mock(), getConnectionCredential: mock(async () => Ok(null))})); + +mock.module('../observability/log', () => ({ + log: {debug: mock(), info: mock(), warn: mock(), error: mock()}, + serializeError: mock(() => ({})), +})); + +mock.module('../utils/env', () => ({env: {NODE_ENV: 'production', APP_URL: 'https://app.example.com', GOOGLE_CLIENT_ID: 'client-1', GOOGLE_CLIENT_SECRET: 'secret-1'}})); + +mock.module('../utils/secret-crypto', () => ({ + encryptSecret: (plaintext: string): Result => Ok(`enc:${plaintext}`), + decryptSecret: (stored: string): Result => Ok(stored.replace(/^enc:/, '')), +})); + +function tasks(): Collection { + const integration = findIntegration('google'); + const found = integration && findCollection(integration, 'tasks'); + if (!found) { + throw new Error('No tasks collection'); + } + return found; +} + +// What a refusal says, or its kind where it says nothing of its own. +function messageOf(result: Result | undefined): string | null { + if (!result || result.isOk()) { + return null; + } + return 'message' in result.error ? result.error.message : result.error.kind; +} + +const CREDENTIAL = {accessToken: 'access-1', refreshToken: 'refresh-1', expiresAt: new Date(Date.now() + 3_600_000).toISOString(), scopes: [], email: 'alex@example.com'}; + +const target = { + connection: { + id: 'google-1', + integrationId: 'google', + orgId: 'org-1', + ownerUserId: 'user-1', + connectedById: 'user-1', + account: 'alex@example.com', + createdAt: new Date(), + tools: null, + name: null, + defaults: [], + credential: `enc:${JSON.stringify(CREDENTIAL)}`, + }, + collection: tasks(), +}; + +const MINE = {id: 'TUlORQ', title: 'My Tasks', updated: '2026-10-10T08:00:00.000Z'}; +const ERRANDS = {id: 'RVJSQU5EUw', title: 'Errands', updated: '2026-10-11T08:00:00.000Z'}; + +// A task made from an email in Gmail, a subtask under it, and one done. +const REPLY = { + id: 'UkVQTFk', + title: 'Reply to Dana about pricing', + notes: 'Send the new tiers', + status: 'needsAction', + due: '2026-10-20T00:00:00.000Z', + updated: '2026-10-12T08:00:00.000Z', + webViewLink: 'https://tasks.google.com/task/UkVQTFk', + links: [{type: 'email', description: 'Pricing review', link: 'https://mail.google.com/mail/#all/18f'}], +}; +const DRAFT = {id: 'RFJBRlQ', title: 'Draft the tiers', status: 'needsAction', parent: 'UkVQTFk', updated: '2026-10-12T09:00:00.000Z'}; +const BOOKED = {id: 'Qk9PS0VE', title: 'Book the venue', status: 'completed', completed: '2026-10-09T10:00:00.000Z', updated: '2026-10-09T10:00:00.000Z', hidden: true}; + +const TASKS: Record = {UkVQTFk: REPLY, RFJBRlQ: DRAFT, Qk9PS0VE: BOOKED}; +const LISTS: Record = {TUlORQ: MINE, RVJSQU5EUw: ERRANDS, '@default': MINE}; + +type Request = {method: string; url: URL; body: string}; + +const google = {requests: [] as Request[], answer: (_request: Request): Response | null => null}; + +spyOn(globalThis, 'fetch').mockImplementation((async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url); + const request = {method: init?.method ?? 'GET', url, body: typeof init?.body === 'string' ? init.body : ''}; + google.requests.push(request); + const custom = google.answer(request); + if (custom) { + return custom; + } + const path = decodeURIComponent(url.pathname); + if (path === '/tasks/v1/users/@me/lists') { + return Response.json({items: [MINE, ERRANDS]}); + } + if (path.startsWith('/tasks/v1/users/@me/lists/')) { + const list = LISTS[path.split('/').at(-1) ?? '']; + return list ? Response.json(list) : Response.json({error: {code: 404, message: 'Not Found'}}, {status: 404}); + } + if (path.endsWith('/move')) { + return Response.json({...REPLY, id: 'TU9WRUQ'}); + } + if (path.endsWith('/tasks') && request.method === 'GET') { + return Response.json({items: [REPLY, DRAFT, BOOKED], nextPageToken: 'google-page-2'}); + } + if (path.endsWith('/tasks') && request.method === 'POST') { + return Response.json({id: 'TkVX', status: 'needsAction', updated: '2026-10-13T08:00:00.000Z', ...JSON.parse(request.body)}); + } + const task = TASKS[path.split('/').at(-1) ?? '']; + if (!task) { + return Response.json({error: {code: 404, message: 'Not Found'}}, {status: 404}); + } + // Google leaves out what was cleared. + if (request.method === 'PATCH') { + return Response.json(Object.fromEntries(Object.entries({...task, ...JSON.parse(request.body)}).filter(([, value]) => value !== null))); + } + return Response.json(task); +}) as typeof fetch); + +beforeEach(() => { + google.requests = []; + google.answer = () => null; +}); + +const query = (rest: Partial<{search: string | null; page: string | null; filter: string | null; parent: string | null}> = {}) => ({search: null, page: null, filter: null, parent: null, ...rest}); + +describe('listing tasks', () => { + test('the top is the account’s task lists, each holding its tasks', async () => { + const {googleConnector} = await import('./google-connector'); + const page = (await googleConnector.list(target, query())).unwrap(); + + expect(page).toEqual({ + records: [ + {id: 'TUlORQ', values: {title: 'My Tasks', kind: 'List'}, updatedAt: '2026-10-10T08:00:00.000Z', hasChildren: true}, + {id: 'RVJSQU5EUw', values: {title: 'Errands', kind: 'List'}, updatedAt: '2026-10-11T08:00:00.000Z', hasChildren: true}, + ], + nextPage: null, + }); + }); + + test('a list holds its tasks, done and hidden ones too, a subtask saying the task it is under', async () => { + const {googleConnector} = await import('./google-connector'); + const page = (await googleConnector.list(target, query({parent: 'TUlORQ'}))).unwrap(); + + const request = google.requests.find((one) => one.url.pathname.endsWith('/tasks')); + expect(request?.url.pathname).toBe('/tasks/v1/lists/TUlORQ/tasks'); + expect(Object.fromEntries(request?.url.searchParams ?? [])).toEqual({maxResults: '50', showCompleted: 'true', showHidden: 'true', showAssigned: 'true'}); + expect(page.trail).toEqual([{id: 'TUlORQ', title: 'My Tasks'}]); + expect(page.nextPage).toBe('google-page-2'); + expect(page.records[0]).toEqual({ + id: 'TUlORQ:UkVQTFk', + values: { + title: 'Reply to Dana about pricing', + kind: 'Task', + notes: 'Send the new tiers', + due: '2026-10-20', + list: 'My Tasks', + status: 'Needs action', + completed: '', + subtaskOf: '', + source: 'https://mail.google.com/mail/#all/18f', + }, + updatedAt: '2026-10-12T08:00:00.000Z', + url: 'https://tasks.google.com/task/UkVQTFk', + }); + expect(page.records.slice(1).map((record) => [record.values.subtaskOf, record.values.status, record.values.completed])).toEqual([ + ['Reply to Dana about pricing', 'Needs action', ''], + ['', 'Done', '2026-10-09T10:00:00.000Z'], + ]); + }); + + test('narrowed to those still to do, done ones are left out; to those done, only they are asked for', async () => { + const {googleConnector} = await import('./google-connector'); + await googleConnector.list(target, query({parent: 'TUlORQ', filter: 'Needs action'})); + await googleConnector.list(target, query({parent: 'TUlORQ', filter: 'Done', page: 'google-page-2'})); + + const [open, done] = google.requests.filter((one) => one.url.pathname.endsWith('/tasks')).map((one) => Object.fromEntries(one.url.searchParams)); + expect(open).toEqual({maxResults: '50', showCompleted: 'false', showHidden: 'false', showAssigned: 'true'}); + expect(done).toEqual({maxResults: '50', showCompleted: 'true', showHidden: 'true', showAssigned: 'true', completedMin: '1970-01-01T00:00:00.000Z', pageToken: 'google-page-2'}); + }); + + test('a task holds nothing, and a list Google doesn’t have is not found', async () => { + const {googleConnector} = await import('./google-connector'); + const inTask = await googleConnector.list(target, query({parent: 'TUlORQ:UkVQTFk'})); + const missing = await googleConnector.list(target, query({parent: 'Tk9QRQ'})); + + expect(messageOf(inTask)).toBe('Tasks hold nothing: give a list’s id as parent'); + expect(missing.isErr() && missing.error.kind).toBe('not_found'); + }); +}); + +describe('opening a task', () => { + test('a subtask opens with the title of the task it is under', async () => { + const {googleConnector} = await import('./google-connector'); + const record = (await googleConnector.get(target, 'TUlORQ:RFJBRlQ')).unwrap(); + + expect(record.values.subtaskOf).toBe('Reply to Dana about pricing'); + }); + + test('a list opens as itself', async () => { + const {googleConnector} = await import('./google-connector'); + const record = (await googleConnector.get(target, 'RVJSQU5EUw')).unwrap(); + + expect(record.values).toEqual({title: 'Errands', kind: 'List'}); + }); +}); + +describe('making a task', () => { + test('in the list it is made inside, with its notes and due day', async () => { + const {googleConnector} = await import('./google-connector'); + const record = (await googleConnector.create(target, {title: 'Call the venue', notes: 'Ask about parking', due: '2026-10-21', list: ''}, 'RVJSQU5EUw')).unwrap(); + + const post = google.requests.find((one) => one.method === 'POST'); + expect(post?.url.pathname).toBe('/tasks/v1/lists/RVJSQU5EUw/tasks'); + expect(JSON.parse(post?.body ?? '')).toEqual({title: 'Call the venue', notes: 'Ask about parking', due: '2026-10-21T00:00:00.000Z'}); + expect(record.id).toBe('RVJSQU5EUw:TkVX'); + expect(record.values.list).toBe('Errands'); + }); + + test('in the list named, else in the default one', async () => { + const {googleConnector} = await import('./google-connector'); + await googleConnector.create(target, {title: 'Buy stamps', notes: '', due: '', list: 'errands'}, null); + const named = google.requests.find((one) => one.method === 'POST'); + google.requests = []; + const record = (await googleConnector.create(target, {title: 'Buy stamps', notes: '', due: '', list: ''}, null)).unwrap(); + + expect(named?.url.pathname).toBe('/tasks/v1/lists/RVJSQU5EUw/tasks'); + expect(google.requests.map((one) => decodeURIComponent(one.url.pathname))).toEqual(['/tasks/v1/users/@me/lists/@default', '/tasks/v1/lists/TUlORQ/tasks']); + expect(record.id).toBe('TUlORQ:TkVX'); + }); + + test('no title, a due date with a time, or a list there isn’t, is turned down', async () => { + const {googleConnector} = await import('./google-connector'); + const results = await Promise.all([ + googleConnector.create(target, {title: ' ', notes: '', due: '', list: ''}, 'TUlORQ'), + googleConnector.create(target, {title: 'Call', notes: '', due: '2026-10-21T09:00', list: ''}, 'TUlORQ'), + googleConnector.create(target, {title: 'Call', notes: '', due: '', list: 'Groceries'}, null), + ]); + + expect(results.map(messageOf)).toEqual([ + 'A task needs a title', + 'Write due as a day, 2026-10-15, since Google Tasks keeps no time of day, not “2026-10-21T09:00”', + 'No task list is called “Groceries”: write one of My Tasks, Errands', + ]); + expect(google.requests.filter((one) => one.method === 'POST')).toEqual([]); + }); +}); + +describe('editing a task', () => { + test('only what was written differently changes, and an empty due day takes it off', async () => { + const {googleConnector} = await import('./google-connector'); + await googleConnector.update(target, 'TUlORQ:UkVQTFk', {title: 'Reply to Dana about pricing', notes: 'Send the new tiers and the FAQ', due: '', list: 'My Tasks'}); + + const patch = google.requests.find((one) => one.method === 'PATCH'); + expect(patch?.url.pathname).toBe('/tasks/v1/lists/TUlORQ/tasks/UkVQTFk'); + expect(JSON.parse(patch?.body ?? '')).toEqual({notes: 'Send the new tiers and the FAQ', due: null}); + }); + + test('writing another list’s name moves the task there', async () => { + const {googleConnector} = await import('./google-connector'); + const record = (await googleConnector.update(target, 'TUlORQ:UkVQTFk', {title: 'Reply to Dana about pricing', notes: 'Send the new tiers', due: '2026-10-20', list: 'Errands'})).unwrap(); + + const move = google.requests.find((one) => one.url.pathname.endsWith('/move')); + expect(move?.method).toBe('POST'); + expect(move?.url.pathname).toBe('/tasks/v1/lists/TUlORQ/tasks/UkVQTFk/move'); + expect(move?.url.searchParams.get('destinationTasklist')).toBe('RVJSQU5EUw'); + expect(google.requests.some((one) => one.method === 'PATCH')).toBe(false); + expect(record.id).toBe('RVJSQU5EUw:TU9WRUQ'); + expect(record.values.list).toBe('Errands'); + }); + + test('nothing written differently changes nothing', async () => { + const {googleConnector} = await import('./google-connector'); + await googleConnector.update(target, 'TUlORQ:UkVQTFk', {title: 'Reply to Dana about pricing', notes: 'Send the new tiers', due: '2026-10-20', list: 'My Tasks'}); + + expect(google.requests.filter((one) => one.method !== 'GET')).toEqual([]); + }); +}); + +describe('completing and reopening', () => { + test('completing marks the task done; reopening marks it to do again and clears when it was done', async () => { + const {googleConnector} = await import('./google-connector'); + const completed = (await googleConnector.commands?.complete?.(target, 'TUlORQ:UkVQTFk'))?.unwrap(); + const reopened = (await googleConnector.commands?.reopen?.(target, 'TUlORQ:Qk9PS0VE'))?.unwrap(); + + const patches = google.requests.filter((one) => one.method === 'PATCH').map((one) => JSON.parse(one.body)); + expect(patches).toEqual([{status: 'completed'}, {status: 'needsAction', completed: null}]); + expect(completed?.values.status).toBe('Done'); + expect(reopened?.values.status).toBe('Needs action'); + }); + + test('tasks are never deleted here', async () => { + const {googleConnector} = await import('./google-connector'); + const result = await googleConnector.remove(target, 'TUlORQ:UkVQTFk'); + + expect(messageOf(result)).toBe('Tasks in Google Tasks are completed, never deleted here'); + expect(google.requests).toEqual([]); + }); +}); diff --git a/packages/api/src/records/google-tasks.ts b/packages/api/src/records/google-tasks.ts new file mode 100644 index 00000000..f03d60c2 --- /dev/null +++ b/packages/api/src/records/google-tasks.ts @@ -0,0 +1,272 @@ +import {Err, Ok, type Result} from 'ts-results-es'; + +import {ApiErr, type ApiError, Do} from '@proxy/utils'; + +import {callGoogle} from '../connections/google/google-client'; +import {getTask, getTaskList, insertTask, listTaskLists, listTasks, moveTask, patchTask, type Task, type TaskList, type TaskStatus, type TaskWrite} from '../connections/google/tasks-api'; +import {realDay} from './calendar-time'; +import {requireFirstPage, type CommandRunner, type Connector, type DataRecord, type ListQuery, type RecordPage, type RecordTarget, type RecordValues} from './connector'; + +/** + * Google Tasks as the nested `tasks` collection, fetched live: at the top the account's task lists, + * and inside each its tasks, subtasks among them, in Google's own order, 50 to a page + * with Google's own page token, all of them or narrowed to those still to do or done. + * + * A list's record id is Google's list id; a task's is its list's and its own, `MTU…:dGFz…`, since a + * task is only found in its list. A due date is a day alone, as Google Tasks keeps no time of day. + * A task is made at the top of the list it is made in, the default list unless another is named, + * completed and reopened by its commands, and moved to another list by writing that list's name. + * Tasks and lists are never deleted here, and lists are only read. + */ + +// Google's ids for lists and tasks; never a colon, which ends a task's list. +const ID = /^[A-Za-z0-9_+/=-]{1,300}$/; + +const NEVER = new Date(0).toISOString(); + +// Google's own name for the account's default list, which a task made outside any list goes in. +const DEFAULT_LIST = '@default'; + +type TaskRef = {listId: string; taskId: string}; + +function taskRecordId(listId: string, taskId: string): string { + return `${listId}:${taskId}`; +} + +function parseTaskRef(recordId: string): TaskRef | null { + const colon = recordId.lastIndexOf(':'); + const listId = recordId.slice(0, colon); + const taskId = recordId.slice(colon + 1); + if (colon < 0 || !ID.test(listId) || !ID.test(taskId)) { + return null; + } + return {listId, taskId}; +} + +function requireTaskRef(recordId: string): Result { + const ref = parseTaskRef(recordId); + if (!ref) { + return Err(ApiErr.notFound('record', recordId)); + } + return Ok(ref); +} + +// A list opened inside a record, or a task made in one, is in a list: tasks hold nothing here. +function requireListId(recordId: string): Result { + if (parseTaskRef(recordId)) { + return Err(ApiErr.validationError('Tasks hold nothing: give a list’s id as parent')); + } + if (!ID.test(recordId)) { + return Err(ApiErr.notFound('record', recordId)); + } + return Ok(recordId); +} + +// Google says a list or task that isn't there is missing; the agent asked for a record. +function notFoundAs(recordId: string) { + return (error: ApiError): ApiError => (error.kind === 'not_found' ? ApiErr.notFound('record', recordId) : error); +} + +function requireList(token: string, listId: string): Promise> { + return getTaskList(token, listId).then((list) => list.mapErr(notFoundAs(listId))); +} + +// A task deleted in Google Tasks is gone, though Google may still answer for it. +function requireTask(token: string, ref: TaskRef, recordId: string): Promise> { + return Do(async ($) => { + const task = $((await getTask(token, ref.listId, ref.taskId)).mapErr(notFoundAs(recordId))); + if (task.deleted) { + return $(Err(ApiErr.notFound('record', recordId))); + } + return task; + }); +} + +function listTitle(list: TaskList): string { + return list.title ?? list.id; +} + +function listRecord(list: TaskList): DataRecord { + return {id: list.id, values: {title: listTitle(list), kind: 'List'}, updatedAt: list.updated ?? NEVER, hasChildren: true}; +} + +// A subtask is under a task: by its title where it is known, else by its record id. +function taskValues(task: Task, list: TaskList, parentTitle: string): RecordValues { + return { + title: task.title ?? '', + kind: 'Task', + notes: task.notes ?? '', + due: task.due?.slice(0, 10) ?? '', + list: listTitle(list), + status: task.status === 'completed' ? 'Done' : 'Needs action', + completed: task.completed ?? '', + subtaskOf: task.parent ? parentTitle || taskRecordId(list.id, task.parent) : '', + source: task.links?.find((link) => link.link)?.link ?? '', + }; +} + +function taskRecord(task: Task, list: TaskList, parentTitle = ''): DataRecord { + return {id: taskRecordId(list.id, task.id), values: taskValues(task, list, parentTitle), updatedAt: task.updated ?? NEVER, ...(task.webViewLink ? {url: task.webViewLink} : {})}; +} + +// The title of the task a subtask is under, read from Google; none where it can't be. +async function parentTitleOf(token: string, listId: string, task: Task): Promise { + if (!task.parent) { + return ''; + } + const parent = await getTask(token, listId, task.parent); + return parent.isOk() ? (parent.value.title ?? '') : ''; +} + +const STATUSES: Partial> = {'Needs action': 'open', Done: 'done'}; + +/** The task lists, or a page of one list's tasks, narrowed to those still to do or done. */ +function listTasksTree(target: RecordTarget, query: ListQuery): Promise> { + return callGoogle(target.connection, (token) => + Do(async ($) => { + if (query.parent === null) { + $(requireFirstPage(query)); + return {records: $(await listTaskLists(token)).map(listRecord), nextPage: null}; + } + const list = $(await requireList(token, $(requireListId(query.parent)))); + const page = $(await listTasks(token, list.id, {status: STATUSES[query.filter ?? ''] ?? null, page: query.page})); + const tasks = page.tasks.filter((task) => !task.deleted); + const titles = new Map(tasks.map((task) => [task.id, task.title ?? ''])); + return { + records: tasks.map((task) => taskRecord(task, list, titles.get(task.parent ?? '') ?? '')), + nextPage: page.nextPage, + trail: [{id: list.id, title: listTitle(list)}], + }; + }), + ); +} + +function getTasksRecord(target: RecordTarget, recordId: string): Promise> { + return callGoogle(target.connection, (token) => + Do(async ($) => { + const ref = parseTaskRef(recordId); + if (!ref) { + return listRecord($(await requireList(token, $(requireListId(recordId))))); + } + const task = $(await requireTask(token, ref, recordId)); + const list = $(await requireList(token, ref.listId)); + return taskRecord(task, list, await parentTitleOf(token, ref.listId, task)); + }), + ); +} + +function parseDue(text: string): Result { + const trimmed = text.trim(); + if (trimmed === '') { + return Ok(null); + } + if (!realDay(trimmed)) { + return Err(ApiErr.validationError(`Write due as a day, 2026-10-15, since Google Tasks keeps no time of day, not “${trimmed}”`)); + } + return Ok(`${trimmed}T00:00:00.000Z`); +} + +function requireTitle(text: string | undefined): Result { + const title = (text ?? '').trim(); + if (title === '') { + return Err(ApiErr.validationError('A task needs a title')); + } + return Ok(title); +} + +// The list written by its name, or its id, among the account's lists. +function requireNamedList(token: string, name: string): Promise> { + return Do(async ($) => { + const lists = $(await listTaskLists(token)); + const wanted = name.trim().toLowerCase(); + const found = lists.find((list) => list.id === name.trim()) ?? lists.find((list) => listTitle(list).toLowerCase() === wanted); + if (!found) { + return $(Err(ApiErr.validationError(`No task list is called “${name.trim()}”: write one of ${lists.map(listTitle).join(', ')}`))); + } + return found; + }); +} + +// The list a new task goes in: the one it is made inside, else the one named, else the default. +function listForNew(token: string, values: RecordValues, parent: string | null): Promise> { + return Do(async ($) => { + if (parent !== null) { + return $(await requireList(token, $(requireListId(parent)))); + } + if ((values.list ?? '').trim() !== '') { + return $(await requireNamedList(token, values.list ?? '')); + } + return $(await requireList(token, DEFAULT_LIST)); + }); +} + +/** A task at the top of the list given, or the one named, or the default list. */ +function createTask(target: RecordTarget, values: RecordValues, parent: string | null): Promise> { + return callGoogle(target.connection, (token) => + Do(async ($) => { + const title = $(requireTitle(values.title)); + const due = $(parseDue(values.due ?? '')); + const list = $(await listForNew(token, values, parent)); + const notes = values.notes?.trim() ? values.notes : undefined; + const made = $(await insertTask(token, list.id, {title, ...(notes ? {notes} : {}), ...(due ? {due} : {})})); + return taskRecord(made, list); + }), + ); +} + +/** Changes what was written differently: the title, notes and due date, and the list, which moves the task there. */ +function updateTask(target: RecordTarget, recordId: string, values: RecordValues): Promise> { + return callGoogle(target.connection, (token) => + Do(async ($) => { + const ref = $(requireTaskRef(recordId)); + const task = $(await requireTask(token, ref, recordId)); + const list = $(await requireList(token, ref.listId)); + const before = taskValues(task, list, ''); + const changed = (key: string) => (values[key] ?? '').trim() !== (before[key] ?? '').trim(); + const changes: TaskWrite = {}; + if (changed('title')) { + changes.title = $(requireTitle(values.title)); + } + if (changed('notes')) { + changes.notes = values.notes ?? ''; + } + if (changed('due')) { + changes.due = $(parseDue(values.due ?? '')); + } + const destination = changed('list') ? $(await requireNamedList(token, values.list ?? '')) : list; + const written = Object.keys(changes).length > 0 ? $((await patchTask(token, ref.listId, ref.taskId, changes)).mapErr(notFoundAs(recordId))) : task; + if (destination.id === list.id) { + return taskRecord(written, list, await parentTitleOf(token, list.id, written)); + } + const moved = $((await moveTask(token, ref.listId, ref.taskId, destination.id)).mapErr(notFoundAs(recordId))); + return taskRecord(moved, destination); + }), + ); +} + +// Completes a task or opens it again; Google clears when it was completed as it is reopened. +function setStatus(status: 'needsAction' | 'completed'): CommandRunner { + return (target, recordId) => + callGoogle(target.connection, (token) => + Do(async ($) => { + const ref = $(requireTaskRef(recordId)); + const list = $(await requireList(token, ref.listId)); + const changes: TaskWrite = status === 'completed' ? {status} : {status, completed: null}; + const task = $((await patchTask(token, ref.listId, ref.taskId, changes)).mapErr(notFoundAs(recordId))); + return taskRecord(task, list, await parentTitleOf(token, ref.listId, task)); + }), + ); +} + +function notDeleted(): Promise> { + return Promise.resolve(Err(ApiErr.validationError('Tasks in Google Tasks are completed, never deleted here'))); +} + +export const tasksConnector: Connector = { + list: listTasksTree, + get: getTasksRecord, + create: createTask, + update: updateTask, + remove: notDeleted, + commands: {complete: setStatus('completed'), reopen: setStatus('needsAction')}, +}; diff --git a/packages/api/src/utils/env-schema.ts b/packages/api/src/utils/env-schema.ts index 72483b7d..77e054b8 100644 --- a/packages/api/src/utils/env-schema.ts +++ b/packages/api/src/utils/env-schema.ts @@ -70,7 +70,7 @@ export const envSchema = z.object({ GOOGLE_CLIENT_ID: documented(z.string().min(1).optional(), { group: 'Sign-in', description: - 'Turns on Google sign-in, with `GOOGLE_CLIENT_SECRET`, and connecting a Google account (Gmail, Calendar, Drive, Docs, Sheets, Slides and Contacts). Register `/auth/google/callback` and `/api/connections/google/callback` as redirect URIs, enable the Gmail, Calendar, Drive, Docs, Sheets, Slides and People APIs in the client’s Cloud project, and set the app’s publishing status to In production: in Testing, Google ends a connection’s sign-in after 7 days.', + 'Turns on Google sign-in, with `GOOGLE_CLIENT_SECRET`, and connecting a Google account (Gmail, Calendar, Drive, Docs, Sheets, Slides, Contacts and Tasks). Register `/auth/google/callback` and `/api/connections/google/callback` as redirect URIs, enable the Gmail, Calendar, Drive, Sheets, People and Tasks APIs in the client’s Cloud project, and set the app’s publishing status to In production: in Testing, Google ends a connection’s sign-in after 7 days.', }), GOOGLE_CLIENT_SECRET: documented(z.string().min(1).optional(), { group: 'Sign-in', diff --git a/packages/db/scripts/seed.ts b/packages/db/scripts/seed.ts index 6d6401a8..20454a46 100644 --- a/packages/db/scripts/seed.ts +++ b/packages/db/scripts/seed.ts @@ -247,11 +247,11 @@ const googleAccount = await connect({ 'https://www.googleapis.com/auth/gmail.send', 'https://www.googleapis.com/auth/calendar.events', 'https://www.googleapis.com/auth/calendar.calendarlist.readonly', + 'https://www.googleapis.com/auth/calendar.events.freebusy', 'https://www.googleapis.com/auth/drive', - 'https://www.googleapis.com/auth/documents', - 'https://www.googleapis.com/auth/spreadsheets', - 'https://www.googleapis.com/auth/presentations', 'https://www.googleapis.com/auth/contacts', + 'https://www.googleapis.com/auth/contacts.other.readonly', + 'https://www.googleapis.com/auth/tasks', ], email: 'demo.user@gmail.com', }), diff --git a/packages/docs/content/connections/google.mdx b/packages/docs/content/connections/google.mdx index 81232cc0..c3149da4 100644 --- a/packages/docs/content/connections/google.mdx +++ b/packages/docs/content/connections/google.mdx @@ -1,11 +1,11 @@ --- title: Google -description: Connect a Google account for agents to work with Gmail, Calendar, Drive, Docs, Sheets, Slides and Contacts. +description: Connect a Google account for agents to work with Gmail, Calendar, Drive, Docs, Sheets, Slides, Contacts and Tasks. --- -Agents work with the account's email, calendars, files and contacts, as the person who signs in. -Every request goes to Google live; nothing is copied. One sign-in covers Gmail, Calendar, Drive, -Docs, Sheets, Slides and Contacts, and one connection holds them all. Gmail needs no app password, so it works for accounts +Agents work with the account's email, calendars, files, contacts and tasks, as the person who signs +in. Every request goes to Google live; nothing is copied. One sign-in covers Gmail, Calendar, Drive, +Docs, Sheets, Slides, Contacts and Tasks, and one connection holds them all. Gmail needs no app password, so it works for accounts that can't make one. ## Before you start: a Google Cloud OAuth client @@ -37,9 +37,10 @@ address, such as `http://192.168.1.20` or `http://nas.lan`, won't register. ### Enable the APIs -Enable the Gmail API, Google Calendar API, Google Drive API, Google Docs API, Google Sheets API, -Google Slides API and People API in the client's project. [This link](https://console.cloud.google.com/flows/enableapi?apiid=gmail.googleapis.com,calendar-json.googleapis.com,drive.googleapis.com,docs.googleapis.com,sheets.googleapis.com,slides.googleapis.com,people.googleapis.com) -asks for the project and enables all seven. +Enable the Gmail API, Google Calendar API, Google Drive API, Google Sheets API, People API and +Google Tasks API in the client's project. [This link](https://console.cloud.google.com/flows/enableapi?apiid=gmail.googleapis.com,calendar-json.googleapis.com,drive.googleapis.com,sheets.googleapis.com,people.googleapis.com,tasks.googleapis.com) +asks for the project and enables all six. Documents and presentations are read and written through +Drive, so the Docs and Slides APIs aren't needed. ### Add the scopes @@ -53,11 +54,11 @@ https://www.googleapis.com/auth/gmail.modify https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/calendar.events https://www.googleapis.com/auth/calendar.calendarlist.readonly +https://www.googleapis.com/auth/calendar.events.freebusy https://www.googleapis.com/auth/drive -https://www.googleapis.com/auth/documents -https://www.googleapis.com/auth/spreadsheets -https://www.googleapis.com/auth/presentations https://www.googleapis.com/auth/contacts +https://www.googleapis.com/auth/contacts.other.readonly +https://www.googleapis.com/auth/tasks ``` ### Choose the audience @@ -114,13 +115,13 @@ connection new tokens and keeps its settings. Google's consent screen lists every permission Personal Agent Proxy asks for, and the person can untick some. An action whose permission was unticked is unavailable to every agent, marked "Not allowed at sign-in", until the person signs in again from the connection's page. A connection made -before Calendar and Contacts joined has their actions marked the same way, until it is signed in -again. +before an action joined, such as Calendar, Contacts, who is free, other contacts or Tasks, has that +action marked the same way, until it is signed in again. ### Review the defaults A new connection gives agents nothing, as a new mailbox gives agents no access to its email. The -connection's page shows the actions under Gmail, Calendar, Drive and Contacts, each saying how many +connection's page shows the actions under Gmail, Calendar, Drive, Contacts and Tasks, each saying how many are on, with **Enable all** above them, or **Disable all** once every one is on. Turn actions on or off for every agent there, or only for one agent on the agent's page. A line above the permissions says what that means for the account: what is ticked is what agents can read and do with it. @@ -155,6 +156,13 @@ with their offset, `2026-10-15T14:00:00+02:00`, and an all-day event as its firs - **Read events** lists, searches and opens events: their times, location, description, guests and their answers, the organizer, the video call and whether they repeat. +- **See when people are free** checks when the account and the people named, by their addresses, + are busy, as Google's free/busy says, without what they are doing, and answers the times all of + them are free, nights included. It doesn't require Read events. It checks from the time given + to the time given, from now and for a week where they are left out; a time is written as an + event's is, and a day alone is the whole day. Every time is answered on the primary calendar's + clock. Someone whose calendar the account can't see, as is usual outside a Google Workspace + organization, is said to be so rather than free, and the free times say they leave them out. - **Create and edit events** makes events in a calendar the account can edit, the primary one unless another is named, and changes their title, times, location and description, and requires Read events. A time is written as `2026-10-15T14:00`, on the calendar's clock, or with an offset; @@ -210,11 +218,30 @@ number or company, and answers the first 30 it finds. Emails and phone numbers a addresses one a line, and a birthday is `1990-04-12`, or `04-12` without the year. - **Read contacts** lists, searches and opens contacts. +- **Read other contacts** lists, searches and opens the people Gmail keeps on its own under Other + contacts: those the account emailed or was emailed by but never saved. Google keeps only their + names, email addresses and phone numbers, and they are only read. With Add and edit contacts as + well, an agent can save one, which copies it into Google Contacts as a contact of its own. - **Add and edit contacts** adds contacts and changes their details, and requires Read contacts. An edit changes only the fields written differently, and an email, number or address still there keeps its label, such as work or home. -Contacts are never deleted. The people Gmail saves on its own, under Other contacts, aren't listed. +Contacts are never deleted. + +For Tasks, agents see the account's task lists, and inside each its tasks in Google's own order, +subtasks and tasks assigned from Docs and Chat among them, 50 to a page: all of them, or only those +still to do or done. Tasks completed in Google Tasks, which it hides there, are listed as done. A due date is a day +alone, `2026-10-15`, as Google Tasks keeps no time of day. + +- **Read tasks** lists and opens task lists and tasks: their title, notes, due day, list, whether + they are done and when, the task a subtask is under, and the email a task was made from in + Gmail. +- **Add and edit tasks** adds a task at the top of a list, the default list unless another is + named, changes its title, notes and due day, moves it to another list when that list's name is + written, and completes or reopens it, and requires Read tasks. A subtask moved to another list + goes to its top level; Google doesn't move a repeating task to another list. + +Tasks and task lists are never deleted, and lists are only read. ## Troubleshooting diff --git a/packages/docs/content/index.mdx b/packages/docs/content/index.mdx index ed3d032f..d97f1a43 100644 --- a/packages/docs/content/index.mdx +++ b/packages/docs/content/index.mdx @@ -15,7 +15,7 @@ write email, calculate, convert units and show balances or spending from the ser The connection guides explain the integrations available today: the [Vault](/connections/vault) for what agents should know about you, [Email](/connections/email) for IMAP and SMTP mailboxes, -[Google](/connections/google) for Gmail, Calendar, Drive, Docs, Sheets, Slides and Contacts, +[Google](/connections/google) for Gmail, Calendar, Drive, Docs, Sheets, Slides, Contacts and Tasks, [Granola](/connections/granola) for meeting notes and transcripts, [Notion](/connections/notion) for pages and databases, [Linear](/connections/linear) for issues, [Cloudflare](/connections/cloudflare) for zones, DNS, Workers and the rest of an account, diff --git a/packages/integrations/src/catalog.ts b/packages/integrations/src/catalog.ts index a270e975..2557f6b1 100644 --- a/packages/integrations/src/catalog.ts +++ b/packages/integrations/src/catalog.ts @@ -824,7 +824,7 @@ const INVITED: Condition = { const FILE_KINDS = ['Folder', 'Document', 'Spreadsheet', 'Presentation', 'Sheet', 'Row', 'File']; // A Google account, signed in to once for everything Personal Agent Proxy reads and writes there: -// Gmail, Calendar, Drive, Docs, Sheets, Slides and Contacts. One connection holds every product, so +// Gmail, Calendar, Drive, Docs, Sheets, Slides, Contacts and Tasks. One connection holds every product, so // action ids carry the product, such as `readEmails` and `sendEmails`, and each action // names its product as its group. Gmail is the same email as any mailbox's, its labels as folders: // all mail but Spam and Trash in one list, narrowed to the inbox, drafts, sent mail, the archive, @@ -834,13 +834,16 @@ const FILE_KINDS = ['Folder', 'Document', 'Spreadsheet', 'Presentation', 'Sheet' // holding its tabs and a tab its rows. Documents read as Markdown, presentations as text, rows as // "Header: value" lines; new documents are written as Markdown and new spreadsheets as CSV, which // Drive converts, and a spreadsheet's ranges are read and written in A1 notation as CSV. Files are -// moved to Trash, never deleted, and neither are events or contacts. Nothing is copied: every +// moved to Trash, never deleted, and neither are events, contacts or tasks. Who is free is asked +// of Google for the people named, and the people Gmail keeps under Other contacts are only read. +// Tasks are one tree: the task lists at the top, each holding its tasks. Nothing is copied: every // request goes to Google. const google: Integration = { id: 'google', signIn: true, name: 'Google', - description: 'Gmail, Calendar, Drive, Docs, Sheets, Slides and Contacts for one Google account', + description: + 'Gmail, Calendar, Drive, Docs, Sheets, Slides, Contacts and Tasks for one Google account', // Google's Limited Use policy asks for what happens with its data to be said where access is given. disclosure: 'What is ticked here is what your agents can read and do with this Google account.', actions: [ @@ -906,6 +909,13 @@ const google: Integration = { risk: 'low', group: 'Calendar', }, + { + id: 'readAvailability', + label: 'See when people are free', + description: 'See when you and the people you name are busy, without what they are doing', + risk: 'low', + group: 'Calendar', + }, { id: 'writeEvents', label: 'Create and edit events', @@ -973,6 +983,13 @@ const google: Integration = { risk: 'low', group: 'Contacts', }, + { + id: 'readOtherContacts', + label: 'Read other contacts', + description: 'List and search the people you emailed, whom Gmail keeps under Other contacts', + risk: 'low', + group: 'Contacts', + }, { id: 'writeContacts', label: 'Add and edit contacts', @@ -981,6 +998,21 @@ const google: Integration = { requires: 'readContacts', group: 'Contacts', }, + { + id: 'readTasks', + label: 'Read tasks', + description: 'See your task lists and the tasks in them', + risk: 'low', + group: 'Tasks', + }, + { + id: 'writeTasks', + label: 'Add and edit tasks', + description: 'Add tasks, change them, move them to another list, and complete or reopen them', + risk: 'medium', + requires: 'readTasks', + group: 'Tasks', + }, ], collections: [ emailsCollection({ @@ -1077,6 +1109,42 @@ const google: Integration = { {key: 'meeting', label: 'Video call', type: 'text', system: true}, ], }, + // When the account and the people named are busy, as Google's free/busy says, without what + // they are doing, and the times all of them are free. Never listed or opened, only checked. A + // time is written as an event's is, on the primary calendar's clock or with an offset, and a + // day alone is the whole day; times are answered on the primary calendar's clock. + { + id: 'availability', + name: 'Availability', + singular: 'availability', + titleField: 'people', + writes: {}, + commands: [ + { + id: 'check', + on: 'new', + label: 'Check', + action: 'readAvailability', + readOnly: true, + done: 'Checked {}', + tried: 'check {}', + }, + ], + fields: [ + // Whom to check beside the account itself, which is always checked, by their addresses. + {key: 'people', label: 'People', type: 'email'}, + // From now where left out. + {key: 'from', label: 'From', type: 'text'}, + // A week after From where left out. + {key: 'to', label: 'To', type: 'text'}, + // The primary calendar's time zone, which the busy and free times are on. + {key: 'timeZone', label: 'Time zone', type: 'text', system: true}, + // Each person's busy times, one person a line, or that their calendar can't be seen. + {key: 'busy', label: 'Busy', type: 'longtext', system: true}, + // The times everyone whose calendar could be seen is free, one a line, nights included. + {key: 'free', label: 'Free', type: 'longtext', system: true}, + ], + }, { id: 'files', name: 'Files', @@ -1195,6 +1263,93 @@ const google: Integration = { {key: 'notes', label: 'Notes', type: 'longtext'}, ], }, + // The people Gmail keeps under Other contacts: those the account emailed or was emailed by but + // never saved, by their names, addresses and numbers, only read. Saving one makes a contact of + // it in Google Contacts, which needs Add and edit contacts. + { + id: 'otherContacts', + name: 'Other contacts', + singular: 'other contact', + titleField: 'name', + summaryField: 'emails', + searchHint: + 'The start of a name, email address or phone number, as Google Contacts matches them: sam, sam@, +44.', + read: 'readOtherContacts', + writes: {}, + commands: [ + { + id: 'save', + on: 'record', + label: 'Save to contacts', + action: 'writeContacts', + done: 'Saved {} to contacts', + tried: 'save {} to contacts', + }, + ], + fields: [ + {key: 'name', label: 'Name', type: 'text', system: true}, + {key: 'emails', label: 'Emails', type: 'email', system: true}, + {key: 'phones', label: 'Phone numbers', type: 'text', system: true}, + ], + }, + // The account's task lists in Google Tasks, each holding its tasks in Google's own order, + // subtasks among them. A due date is a day alone, 2026-10-15, as Google Tasks keeps it. A task + // is completed or reopened, and moved to another list by writing that list's name; tasks and + // lists are never deleted here. + { + id: 'tasks', + name: 'Tasks', + singular: 'task', + titleField: 'title', + summaryField: 'due', + filterField: 'status', + nested: true, + openIn: 'Google Tasks', + read: 'readTasks', + writes: {create: 'writeTasks', update: 'writeTasks'}, + // Lists are only read. + editable: {field: 'kind', values: ['Task']}, + commands: [ + { + id: 'complete', + on: 'record', + label: 'Complete', + action: 'writeTasks', + where: {field: 'status', values: ['Needs action']}, + done: 'Completed {}', + tried: 'complete {}', + }, + { + id: 'reopen', + on: 'record', + label: 'Reopen', + action: 'writeTasks', + where: {field: 'status', values: ['Done']}, + done: 'Reopened {}', + tried: 'reopen {}', + }, + ], + fields: [ + {key: 'title', label: 'Title', type: 'text'}, + {key: 'kind', label: 'Kind', type: 'select', options: ['List', 'Task'], system: true}, + {key: 'notes', label: 'Notes', type: 'longtext'}, + {key: 'due', label: 'Due', type: 'date'}, + // The list a task is in; writing another list's name moves the task there. + {key: 'list', label: 'List', type: 'text'}, + { + key: 'status', + label: 'Status', + type: 'select', + options: ['Needs action', 'Done'], + system: true, + }, + {key: 'completed', label: 'Completed', type: 'datetime', system: true}, + // The title of the task a subtask is under. + {key: 'subtaskOf', label: 'Subtask of', type: 'text', system: true}, + // What a task was made from, such as an email in Gmail, by its link. + {key: 'source', label: 'Made from', type: 'text', system: true}, + ], + }, ], }; diff --git a/packages/integrations/src/tools.test.ts b/packages/integrations/src/tools.test.ts index 65bd536c..1d08204e 100644 --- a/packages/integrations/src/tools.test.ts +++ b/packages/integrations/src/tools.test.ts @@ -274,7 +274,7 @@ describe('cloudflare', () => { describe('google', () => { const google = integration('google'); - test('email has the same tools as any mailbox’s, files are never deleted, only moved to Trash, ranges are only run, and events and contacts are never deleted', () => { + test('email has the same tools as any mailbox’s, files are never deleted, only moved to Trash, ranges and availability are only run, other contacts are only read and saved, and events, contacts and tasks are never deleted', () => { expect(integrationTools(google).map((tool) => tool.name)).toEqual([ ...integrationTools(email).map((tool) => tool.name), 'events_list', @@ -284,6 +284,7 @@ describe('google', () => { 'events_accept', 'events_decline', 'events_maybe', + 'availability_check', 'files_list', 'files_get', 'files_create', @@ -295,9 +296,67 @@ describe('google', () => { 'contacts_get', 'contacts_create', 'contacts_update', + 'other_contacts_list', + 'other_contacts_get', + 'other_contacts_save', + 'tasks_list', + 'tasks_get', + 'tasks_create', + 'tasks_update', + 'tasks_complete', + 'tasks_reopen', ]); }); + test('checking availability is a read with an action of its own, taking the people and the times', () => { + const check = integrationTools(google).find((tool) => tool.name === 'availability_check'); + + expect(check).toMatchObject({kind: 'newCommand', action: 'readAvailability', readOnly: true}); + expect(Object.keys(check?.inputSchema.properties ?? {})).toEqual(['people', 'from', 'to']); + }); + + test('other contacts are read with an action of their own, and saving one needs Add and edit contacts', () => { + const tools = integrationTools(google).filter((tool) => + tool.name.startsWith('other_contacts_'), + ); + + expect(tools.map((tool) => [tool.name, tool.action, tool.readOnly])).toEqual([ + ['other_contacts_list', 'readOtherContacts', true], + ['other_contacts_get', 'readOtherContacts', true], + ['other_contacts_save', 'writeContacts', false], + ]); + }); + + test('a task is made inside a list, narrowed by its status, and completed or reopened only where it fits', () => { + const tools = integrationTools(google).filter((tool) => tool.name.startsWith('tasks_')); + const named = (name: string) => tools.find((tool) => tool.name === name); + + expect(Object.keys(named('tasks_list')?.inputSchema.properties ?? {})).toEqual([ + 'parent', + 'status', + 'page', + ]); + expect(Object.keys(named('tasks_create')?.inputSchema.properties ?? {})).toEqual([ + 'parent', + 'title', + 'notes', + 'due', + 'list', + ]); + expect(named('tasks_update')).toMatchObject({ + action: 'writeTasks', + where: {field: 'kind', values: ['Task']}, + }); + expect(named('tasks_complete')).toMatchObject({ + action: 'writeTasks', + where: {field: 'status', values: ['Needs action']}, + }); + expect(named('tasks_reopen')).toMatchObject({ + action: 'writeTasks', + where: {field: 'status', values: ['Done']}, + }); + }); + test('an event is made inside a calendar, its guests need Invite guests, and only events are edited', () => { const create = findTool(google, 'events_create'); diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index 493c8afd..2d98c4e2 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -30,8 +30,8 @@ export type Action = { requires?: string; // Reaches people beyond the account, as sending email does. reachesOthers?: true; - // The part of the provider it belongs to, as Google's actions are Gmail's, Calendar's, Drive's or - // Contacts': a connection's permissions show each group's actions under its name. + // The part of the provider it belongs to, as Google's actions are Gmail's, Calendar's, Drive's, + // Contacts' or Tasks': a connection's permissions show each group's actions under its name. group?: string; }; diff --git a/packages/web/src/components/connection-access.test.tsx b/packages/web/src/components/connection-access.test.tsx index e2dce5bf..9251686a 100644 --- a/packages/web/src/components/connection-access.test.tsx +++ b/packages/web/src/components/connection-access.test.tsx @@ -570,12 +570,14 @@ describe('ConnectionAccess with what the sign-in covered', () => { 'Calendar', 'Drive', 'Contacts', + 'Tasks', ]); expect(sections.map((section) => section.querySelector('h3 + span')?.textContent)).toEqual([ '1 of 7 on', 'None on', '2 of 4 on', 'None on', + 'None on', ]); expect( within(sections[1] as HTMLElement).getByRole('checkbox', {name: /^Invite guests/}), diff --git a/packages/web/src/components/connection-access.tsx b/packages/web/src/components/connection-access.tsx index 3375d72e..a8b86588 100644 --- a/packages/web/src/components/connection-access.tsx +++ b/packages/web/src/components/connection-access.tsx @@ -34,7 +34,7 @@ const ROWS = 'flex flex-col gap-4'; // A connection's access, the way an editor shows its settings: one checkbox per action, the only // control for it, under the name of the part of the provider it belongs to where the integration -// groups them, as Google's are Gmail's, Calendar's, Drive's and Contacts', each saying how many are +// groups them, as Google's are Gmail's, Calendar's, Drive's, Contacts' and Tasks', each saying how many are // on, and one button above them all turning every action on, or off once all are. On an agent's // page every action starts on the connection's default; one the agent has its own setting for is // marked with a bar, a button that makes it the default for every agent, and one that resets it to diff --git a/packages/website/src/app/privacy/page.tsx b/packages/website/src/app/privacy/page.tsx index 7cd4287b..0a09f36e 100644 --- a/packages/website/src/app/privacy/page.tsx +++ b/packages/website/src/app/privacy/page.tsx @@ -59,15 +59,17 @@ export default function PrivacyPage() { other financial account, a database or any other service that speaks MCP, we collect the credentials needed to reach it and, depending on the permissions you give your agents, information from that service, which may include: emails and other messages, with their - attachments; calendars, events and invitations, with their guests and responses; contacts; - files, folders, documents, spreadsheets, presentations and pages; notes, meeting notes and - transcripts; issues, tasks, projects and comments; database tables, rows and query - results; financial accounts, with their names, types, the last digits of their numbers, - balances and limits, and their transactions, with amounts, dates, merchants, categories - and locations; anything else a connected service returns to your agents; the metadata of - all of these; and the results of the actions your agents take. Some of this may be - sensitive, such as private communications, financial information or health information, - depending on what you connect and what you let your agents do. + attachments; calendars, events and invitations, with their guests and responses, and when + the people you name are busy; contacts, and the people a service keeps on its own because + you exchanged messages with them; files, folders, documents, spreadsheets, presentations + and pages; notes, meeting notes and transcripts; tasks and to-do lists; issues, projects + and comments; database tables, rows and query results; financial accounts, with their + names, types, the last digits of their numbers, balances and limits, and their + transactions, with amounts, dates, merchants, categories and locations; anything else a + connected service returns to your agents; the metadata of all of these; and the results of + the actions your agents take. Some of this may be sensitive, such as private + communications, financial information or health information, depending on what you connect + and what you let your agents do.

Information about your agents' activity. We @@ -128,11 +130,13 @@ export default function PrivacyPage() { When you connect a Google account, Google asks you to grant the Services access to parts of that account. Depending on what you grant, the Services may access your Google account email address and information in the Google services you grant access to, such as Gmail, - Google Calendar, Google Drive, Google Docs, Google Sheets, Google Slides and Google - Contacts, including the content and metadata of what is in them, and may act on it for - your agents within the permissions you set, such as sending email, creating and changing - documents and events, or moving items to Trash. Google's consent screen lets you - decline any of these. + Google Calendar, Google Drive, Google Docs, Google Sheets, Google Slides, Google Contacts + and Google Tasks, including the content and metadata of what is in them, when the people + you name are free or busy in Google Calendar, and the names, email addresses and phone + numbers of the people Google keeps under Other contacts because you exchanged email with + them, and may act on it for your agents within the permissions you set, such as sending + email, creating and changing documents, events and tasks, or moving items to Trash. + Google's consent screen lets you decline any of these.

We use information received from Google Workspace APIs only to provide and improve the diff --git a/packages/website/src/lib/legal.ts b/packages/website/src/lib/legal.ts index bbb29a3b..09a4db3d 100644 --- a/packages/website/src/lib/legal.ts +++ b/packages/website/src/lib/legal.ts @@ -10,4 +10,4 @@ export const LEGAL = { }; // The day both pages took effect; a change to either moves it. -export const LEGAL_EFFECTIVE_DATE = 'October 15, 2026'; +export const LEGAL_EFFECTIVE_DATE = 'October 26, 2026';