Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/api/src/agent-side/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ describe('runAgentTool', () => {
const result = await runAgentTool(caller, 'mail-1', 'emails_update', {id: draft.id, subject: 'Re: Q4 planning'});

expect(result.isOk()).toBe(true);
expect(connector.update.mock.calls[0]?.slice(1)).toEqual([draft.id, {to: 'sam@example.com', subject: 'Re: Q4 planning', body: 'Draft body'}]);
expect(connector.update.mock.calls[0]?.slice(1)).toEqual([draft.id, {to: 'sam@example.com', subject: 'Re: Q4 planning', body: 'Draft body'}, draft]);
expect(logAgentRequest.mock.calls[0]?.[0]).toMatchObject({action: 'update', recordTitle: 'Re: Q4 planning'});
});

Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/agent-side/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ function update(run: ToolRun, params: Record<string, string>): Promise<Result<To
$(requireApplies(run.tool.where, before));
const current = Object.fromEntries(collection.fields.filter((field) => !field.system).map((field) => [field.key, before.values[field.key] ?? '']));
const values = $(parseRecordValues(collection, {...current, ...fieldsOf(params)}));
const record = $(await run.connector.update(run.target, recordId, values));
const record = $(await run.connector.update(run.target, recordId, values, before));
$(await log(run, 'allowed', recordTitle(collection, record.values)));
return {record};
});
Expand Down
67 changes: 63 additions & 4 deletions packages/api/src/connections/google/drive-api.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import {randomUUID} from 'node:crypto';

import type {Result} from 'ts-results-es';
import {z} from 'zod';

import type {ApiError} from '@proxy/utils';

import {googleRequest, googleText} from './google-client';
import {googleRequest, googleText, googleUpload, googleWrite} from './google-client';

/**
* The Drive v3 calls the `files` collection makes, and the reading of their answers. Every call
* takes shared drives too.
* takes shared drives too. A file is uploaded as Markdown or CSV with the Google type it is to
* become, and Drive converts it.
*/

const DRIVE = 'https://www.googleapis.com/drive/v3';

const UPLOAD = 'https://www.googleapis.com/upload/drive/v3';

export const PAGE_SIZE = 50;

/** Drive's ids, as every id an agent sends is checked against before it reaches an address or a query. */
Expand Down Expand Up @@ -55,8 +60,8 @@ const driveListSchema = z.object({drives: z.array(sharedDriveSchema), nextPageTo

export type Page<T> = {items: T[]; nextPage: string | null};

function driveUrl(path: string, params: Record<string, string>): URL {
const url = new URL(`${DRIVE}${path}`);
function driveUrl(path: string, params: Record<string, string>, base: string = DRIVE): URL {
const url = new URL(`${base}${path}`);
for (const [name, value] of Object.entries(params)) {
url.searchParams.set(name, value);
}
Expand Down Expand Up @@ -117,3 +122,57 @@ export async function listSharedDrives(accessToken: string, search: string | nul
export function getSharedDrive(accessToken: string, driveId: string): Promise<Result<SharedDrive, ApiError>> {
return googleRequest(accessToken, driveUrl(`/drives/${driveId}`, {fields: 'id,name'}), sharedDriveSchema);
}

/** A new file's name, the Google type it is to be, and the folder it goes in. */
export type NewFile = {name: string; mimeType: string; parent: string};

/** What a file is uploaded as: its type, such as `text/markdown`, and its text. */
export type Upload = {type: string; body: string};

// Text uploaded as the UTF-8 it is sent as, so Drive reads letters beyond ASCII as they are.
function textType(type: string): string {
return `${type}; charset=UTF-8`;
}

// A file's metadata and its content in one request, as Drive's multipart upload takes them.
function multipart(metadata: object, upload: Upload): Upload {
const boundary = `proxy-${randomUUID()}`;
const body = [
`--${boundary}`,
'Content-Type: application/json; charset=UTF-8',
'',
JSON.stringify(metadata),
`--${boundary}`,
`Content-Type: ${textType(upload.type)}`,
'',
upload.body,
`--${boundary}--`,
].join('\r\n');
return {type: `multipart/related; boundary=${boundary}`, body};
}

/** Makes a file in a folder: empty, or from what is uploaded, which Drive converts to the file's type. */
export function createFile(accessToken: string, file: NewFile, upload: Upload | null): Promise<Result<DriveFile, ApiError>> {
const metadata = {name: file.name, mimeType: file.mimeType, parents: [file.parent]};
const params = {fields: FILE_FIELDS, supportsAllDrives: 'true'};
if (upload === null) {
return googleWrite(accessToken, 'POST', driveUrl('/files', params), metadata, driveFileSchema);
}
return googleUpload(accessToken, 'POST', driveUrl('/files', {...params, uploadType: 'multipart'}, UPLOAD), multipart(metadata, upload), driveFileSchema);
}

/** Changes a file's metadata, such as its name, or puts it in Trash. */
export function updateFile(accessToken: string, fileId: string, changes: {name?: string; trashed?: true}): Promise<Result<DriveFile, ApiError>> {
return googleWrite(accessToken, 'PATCH', driveUrl(`/files/${fileId}`, {fields: FILE_FIELDS, supportsAllDrives: 'true'}), changes, driveFileSchema);
}

/** Replaces all of a file's content with what is uploaded, which Drive converts to the file's own type. */
export function replaceContent(accessToken: string, fileId: string, upload: Upload): Promise<Result<DriveFile, ApiError>> {
return googleUpload(
accessToken,
'PATCH',
driveUrl(`/files/${fileId}`, {uploadType: 'media', fields: FILE_FIELDS, supportsAllDrives: 'true'}, UPLOAD),
{...upload, type: textType(upload.type)},
driveFileSchema,
);
}
18 changes: 14 additions & 4 deletions packages/api/src/connections/google/google-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ function readGoogleError(error: Extract<FetchError, {kind: 'http'}>): {reasons:
return {reasons, message};
}

/** What reading a file past Google's export limit says, where its content would be. */
export const TOO_LARGE_TO_READ = 'This file is too large to read; Google exports at most 10 MB';

const RATE_LIMITED = ['rateLimitExceeded', 'userRateLimitExceeded', 'RATE_LIMIT_EXCEEDED'];

// What Google says of a call whose token lacks the scope it takes, as sending a draft does with
Expand All @@ -61,7 +64,7 @@ export function googleError(error: FetchError): ApiError {

const {reasons, message} = readGoogleError(error);
if (reasons.includes('exportSizeLimitExceeded')) {
return ApiErr.validationError('This file is too large to read; Google exports at most 10 MB');
return ApiErr.validationError(TOO_LARGE_TO_READ);
}
if (reasons.includes('accessNotConfigured') || reasons.includes('SERVICE_DISABLED') || /has not been used|is disabled/.test(message)) {
return ApiErr.validationError(message);
Expand Down Expand Up @@ -101,9 +104,16 @@ export async function googleText(accessToken: string, url: URL): Promise<Result<
return answer.mapErr(googleError);
}

/** A POST or PUT to one of Google's APIs with a JSON body, its answer read with `schema`. */
export async function googleWrite<S extends z.ZodType>(accessToken: string, method: 'POST' | 'PUT', url: URL, body: object, schema: S): Promise<Result<z.infer<S>, ApiError>> {
const init = {method, headers: {...authHeaders(accessToken), 'content-type': 'application/json'}, body: JSON.stringify(body)};
type WriteMethod = 'POST' | 'PUT' | 'PATCH';

/** A POST, PUT or PATCH to one of Google's APIs with a JSON body, its answer read with `schema`. */
export function googleWrite<S extends z.ZodType>(accessToken: string, method: WriteMethod, url: URL, body: object, schema: S): Promise<Result<z.infer<S>, ApiError>> {
return googleUpload(accessToken, method, url, {type: 'application/json', body: JSON.stringify(body)}, schema);
}

/** A write whose body is of its own type, as a file uploaded to Drive is, its answer read with `schema`. */
export async function googleUpload<S extends z.ZodType>(accessToken: string, method: WriteMethod, url: URL, content: {type: string; body: string}, schema: S): Promise<Result<z.infer<S>, ApiError>> {
const init = {method, headers: {...authHeaders(accessToken), 'content-type': content.type}, body: content.body};
const answer = await httpRequest(url, init, {schema, timeoutMs: TIMEOUT_MS});
return answer.mapErr(googleError);
}
Expand Down
11 changes: 8 additions & 3 deletions packages/api/src/connections/google/scopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ const scope = (name: string): string => `https://www.googleapis.com/auth/${name}
// `gmail.send` alone.
const GMAIL = [scope('gmail.modify')];

// Any one of an action's scopes is enough. Drive's export and the Docs and Sheets APIs all take
// `drive`, so it alone covers reading files.
// Any one of an action's scopes is enough. Drive's export and uploads and the Sheets API take
// `drive` for reading and writing alike, so it alone covers every file action.
const DRIVE = [scope('drive')];

const ACTION_SCOPES: Partial<Record<string, string[]>> = {
readEmails: GMAIL,
markEmails: GMAIL,
Expand All @@ -24,7 +26,10 @@ const ACTION_SCOPES: Partial<Record<string, string[]>> = {
writeEmails: GMAIL,
trashEmails: GMAIL,
sendEmails: [scope('gmail.send'), scope('gmail.modify')],
readFiles: [scope('drive')],
readFiles: DRIVE,
createFiles: DRIVE,
editFiles: DRIVE,
trashFiles: DRIVE,
};

/** The actions a Google sign-in didn't cover, in the catalog's order; one the map leaves out is never covered. */
Expand Down
103 changes: 98 additions & 5 deletions packages/api/src/connections/google/sheets-api.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import type {Result} from 'ts-results-es';
import {z} from 'zod';

import type {ApiError} from '@proxy/utils';
import {ApiErr, type ApiError, Do, requirePresent} from '@proxy/utils';

import {googleRequest} from './google-client';
import {googleRequest, googleWrite} from './google-client';

/**
* The Sheets v4 calls the `files` collection makes, and the reading of their answers: a
* spreadsheet's tabs, and the values in ranges of one.
* The Sheets v4 calls the `files` and `ranges` collections make, and the reading of their answers:
* a spreadsheet's tabs, and the values in ranges of one, read as Sheets shows them and written as
* if typed in, so a number stays a number and `=SUM(B2:B9)` is a formula.
*/

const SHEETS = 'https://sheets.googleapis.com/v4/spreadsheets';
Expand Down Expand Up @@ -52,9 +53,96 @@ export async function getSheets(accessToken: string, spreadsheetId: string): Pro
);
}

// A tab's title as A1 notation quotes it: `'Alex''s notes'`.
function quotedTitle(title: string): string {
return `'${title.replace(/'/g, "''")}'`;
}

/** Rows `from` to `to` of a tab, every column, as A1 notation writes them: `'Q1 Sales'!A2:ZZ51`. */
export function rowsRange(title: string, from: number, to: number): string {
return `'${title.replace(/'/g, "''")}'!A${from}:${LAST_COLUMN}${to}`;
return `${quotedTitle(title)}!A${from}:${LAST_COLUMN}${to}`;
}

/** One cell of a tab, its column counting from 0: `'Q1 Sales'!C5`. */
export function cellRange(title: string, column: number, row: number): string {
return `${quotedTitle(title)}!${columnLetters(column)}${row}`;
}

// The values in a range, the range percent-encoded in the path, and what is done with them, such as `:append`.
function valuesUrl(spreadsheetId: string, range: string, params: Record<string, string> = {}, verb = ''): URL {
const url = new URL(`${SHEETS}/${spreadsheetId}/values/${encodeURIComponent(range)}${verb}`);
for (const [name, value] of Object.entries(params)) {
url.searchParams.set(name, value);
}
return url;
}

// What a write answers: the range it changed, and, where asked for, the values there now.
const updatedSchema = z.object({updatedRange: z.string().optional(), updatedRows: z.number().optional(), updatedData: valueRangeSchema.optional()});

const appendSchema = z.object({updates: z.object({updatedRange: z.string()})});

/** The values in a range, as Sheets shows them, and the range itself as Sheets writes it. */
export async function getValues(accessToken: string, spreadsheetId: string, range: string): Promise<Result<{range: string; values: string[][]}, ApiError>> {
const answer = await googleRequest(accessToken, valuesUrl(spreadsheetId, range), valueRangeSchema.extend({range: z.string()}));
return answer.map((read) => ({range: read.range, values: read.values ?? []}));
}

/** Writes values into a range, as if typed in, and answers the range written and the values there now. */
export async function putValues(accessToken: string, spreadsheetId: string, range: string, values: string[][]): Promise<Result<{range: string; values: string[][]}, ApiError>> {
const url = valuesUrl(spreadsheetId, range, {valueInputOption: 'USER_ENTERED', includeValuesInResponse: 'true', responseValueRenderOption: 'FORMATTED_VALUE'});
const answer = await googleWrite(accessToken, 'PUT', url, {range, majorDimension: 'ROWS', values}, updatedSchema);
return answer.map((written) => ({range: written.updatedRange ?? range, values: written.updatedData?.values ?? []}));
}

// Where a write's answer says it wrote: the part after the last "!", since a tab's title can hold
// one too, as in `'a!B7'!A12:C12`.
const writtenSchema = /^\$?([A-Z]+)\$?(\d+)(?::\$?([A-Z]+)\$?\d+)?$/;

function writtenCells(range: string): Result<{column: number; row: number; lastColumn: number}, ApiError> {
const [, first = '', row = '', last] = writtenSchema.exec(range.slice(range.lastIndexOf('!') + 1)) ?? [];
return requirePresent(row || null, ApiErr.validationError(`Google answered a range that names no row: ${range}`)).map(() => ({
column: columnIndex(first),
row: Number(row),
lastColumn: columnIndex(last ?? first),
}));
}

/**
* Adds a row under the last of a tab's rows that hold anything, as if typed in, and answers the
* row's number. Sheets appends after the last block of rows in the range it is given, starting at
* that block's first column, so the whole tab is given; where its last block doesn't start in
* column A, the row is written again from A.
*/
export async function appendRow(accessToken: string, spreadsheetId: string, title: string, cells: string[]): Promise<Result<number, ApiError>> {
const url = valuesUrl(spreadsheetId, quotedTitle(title), {valueInputOption: 'USER_ENTERED', insertDataOption: 'INSERT_ROWS'}, ':append');
return Do(async ($) => {
const {updates} = $(await googleWrite(accessToken, 'POST', url, {majorDimension: 'ROWS', values: [cells]}, appendSchema));
const written = $(writtenCells(updates.updatedRange));
if (written.column === 0) {
return written.row;
}
// The row's own cells from A, and the ones written past them emptied.
const width = Math.max(cells.length, written.lastColumn + 1);
const row = Array.from({length: width}, (_, column) => cells[column] ?? '');
const range = `${quotedTitle(title)}!A${written.row}:${columnLetters(width - 1)}${written.row}`;
$(await putValues(accessToken, spreadsheetId, range, [row]));
return written.row;
});
}

/** Writes each value into its own cell, as if typed in, the cells around them as they are. */
export async function updateCells(accessToken: string, spreadsheetId: string, cells: {range: string; value: string}[]): Promise<Result<void, ApiError>> {
const data = cells.map(({range, value}) => ({range, majorDimension: 'ROWS', values: [[value]]}));
const answer = await googleWrite(accessToken, 'POST', new URL(`${SHEETS}/${spreadsheetId}/values:batchUpdate`), {valueInputOption: 'USER_ENTERED', data}, z.object({}));
return answer.map(() => undefined);
}

/** Renames a tab. */
export async function renameSheet(accessToken: string, spreadsheetId: string, sheetId: number, title: string): Promise<Result<void, ApiError>> {
const request = {updateSheetProperties: {properties: {sheetId, title}, fields: 'title'}};
const answer = await googleWrite(accessToken, 'POST', new URL(`${SHEETS}/${spreadsheetId}:batchUpdate`), {requests: [request]}, z.object({}));
return answer.map(() => undefined);
}

/** The values in each range, in the order asked, one list of cells a row. */
Expand All @@ -67,6 +155,11 @@ export async function getRanges(accessToken: string, spreadsheetId: string, rang
return batch.map(({valueRanges}) => valueRanges.map((range) => range.values ?? []));
}

/** A column's position counting from 0, from its letters: 0 for A, 26 for AA. */
export function columnIndex(letters: string): number {
return [...letters].reduce((index, letter) => index * 26 + (letter.charCodeAt(0) - 64), 0) - 1;
}

/** A column's letters, from its position counting from 0: A, Z, AA. */
export function columnLetters(index: number): string {
const letter = String.fromCharCode(65 + (index % 26));
Expand Down
12 changes: 10 additions & 2 deletions packages/api/src/connections/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,15 @@ describe('handleListConnectionsRoute', () => {
const {handleListConnectionsRoute} = await import('./route');
const [, connection] = (await handleListConnectionsRoute(makeRequest())).unwrap().connections;

expect(connection).toEqual({id: 'conn-2', integrationId: 'google', account: 'alex@example.com', defaults: [], tools: null, name: null, unavailable: ['readFiles']});
expect(connection).toEqual({
id: 'conn-2',
integrationId: 'google',
account: 'alex@example.com',
defaults: [],
tools: null,
name: null,
unavailable: ['readFiles', 'createFiles', 'editFiles', 'trashFiles'],
});
});

test('a user without an organization is signed out', async () => {
Expand Down Expand Up @@ -277,7 +285,7 @@ describe('handleSetConnectionDefaultsRoute', () => {
const {handleSetConnectionDefaultsRoute} = await import('./route');
const result = await handleSetConnectionDefaultsRoute(makeRequest({connectionId: 'conn-2'}, {actions: {readFiles: false}}));

expect(result.unwrap().unavailable).toEqual(['readFiles']);
expect(result.unwrap().unavailable).toEqual(['readFiles', 'createFiles', 'editFiles', 'trashFiles']);
expect(setConnectionDefaults).toHaveBeenCalledWith({connectionId: 'conn-2', actions: {readFiles: false}});
});

Expand Down
4 changes: 2 additions & 2 deletions packages/api/src/connections/unavailable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ describe('unavailableActions', () => {
expect(unavailableActions(google(ALL_SCOPES))).toEqual([]);
});

test('a Google sign-in without Drive leaves Read files out', async () => {
test('a Google sign-in without Drive leaves every files action out', async () => {
const {unavailableActions} = await import('./unavailable');

expect(unavailableActions(google(ALL_SCOPES.filter((scope) => !scope.endsWith('/drive'))))).toEqual(['readFiles']);
expect(unavailableActions(google(ALL_SCOPES.filter((scope) => !scope.endsWith('/drive'))))).toEqual(['readFiles', 'createFiles', 'editFiles', 'trashFiles']);
});

test('a Google sign-in with only gmail.send leaves every Gmail action but Send out', async () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/api/src/records/connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ export type Connector = {
get: (target: RecordTarget, recordId: string) => Promise<Result<DataRecord, ApiError>>;
// In a nested collection, inside `parent` when one is given.
create: (target: RecordTarget, values: RecordValues, parent: string | null) => Promise<Result<DataRecord, ApiError>>;
update: (target: RecordTarget, recordId: string, values: RecordValues) => Promise<Result<DataRecord, ApiError>>;
// `before` is the record as the caller read it, for a connector to tell what was written
// differently from it without reading it again.
update: (target: RecordTarget, recordId: string, values: RecordValues, before?: DataRecord) => Promise<Result<DataRecord, ApiError>>;
remove: (target: RecordTarget, recordId: string) => Promise<Result<void, ApiError>>;
// By the catalog's command id: the record commands, and those on values typed in.
commands?: Partial<Record<string, CommandRunner>>;
Expand Down
Loading
Loading