chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,693 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
const { z } = sdk
|
||||
|
||||
type ActionDefinitions = NonNullable<sdk.IntegrationDefinition['actions']>
|
||||
type ActionDef = ActionDefinitions[string]
|
||||
|
||||
const MAJOR_DIMENSION = ['ROWS', 'COLUMNS'] as const
|
||||
export type MajorDimension = (typeof MAJOR_DIMENSION)[number]
|
||||
|
||||
const _sheetsValue = z
|
||||
.string()
|
||||
.title('Stringified value')
|
||||
.describe('Represents the value of a single cell. This is a stringified number, string or boolean value')
|
||||
|
||||
const _commonFields = {
|
||||
range: z.string().title('Range').placeholder("'Sheet name'!A1:F8"),
|
||||
majorDimension: z.enum(MAJOR_DIMENSION).title('Major Dimension').default('ROWS'),
|
||||
values: z
|
||||
.array(
|
||||
z
|
||||
.array(_sheetsValue)
|
||||
.title('Row or column')
|
||||
.describe('Represents a major dimension (a row or column) of a values range')
|
||||
)
|
||||
.title('Values'),
|
||||
} as const
|
||||
|
||||
const getValues = {
|
||||
title: 'Get Values',
|
||||
description: 'Returns the values of a range in the spreadsheet.',
|
||||
input: {
|
||||
schema: z.object({
|
||||
range: _commonFields.range.describe('The A1 notation of the range to retrieve. (e.g. "Sheet1!A1:B2")'),
|
||||
majorDimension: _commonFields.majorDimension
|
||||
.optional()
|
||||
.describe(
|
||||
'If it equals "ROWS", then the values are returned as rows. If it equals "COLUMNS", then the values are returned as columns.'
|
||||
),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
range: _commonFields.range.describe(
|
||||
'The range the values cover, in A1 notation. This range indicates the entire requested range, even though the values will exclude trailing rows and columns. (e.g. "Sheet1!A1:B2")'
|
||||
),
|
||||
majorDimension: _commonFields.majorDimension.describe(
|
||||
'If it equals "ROWS", then the values are returned in rows. If it equals "COLUMNS", then the values are returned in columns.'
|
||||
),
|
||||
values: _commonFields.values.describe(
|
||||
'The data that was read. This is an array of arrays, the outer array representing all the data and each inner array representing a major dimension (a row or column). Each item in the inner array corresponds with one cell. (e.g. [["a", "b"], ["c", "d"]])'
|
||||
),
|
||||
}),
|
||||
},
|
||||
} as const satisfies ActionDef
|
||||
|
||||
const setValues = {
|
||||
title: 'Set Values',
|
||||
description: 'Sets values in a range in the spreadsheet.',
|
||||
input: {
|
||||
schema: z.object({
|
||||
range: _commonFields.range.describe('The A1 notation of the range to update. (e.g. "Sheet1!A1:B2")'),
|
||||
majorDimension: _commonFields.majorDimension
|
||||
.optional()
|
||||
.describe(
|
||||
'If it equals "ROWS", then the values are inserted as rows. If it equals "COLUMNS", then the values are inserted as columns.'
|
||||
),
|
||||
values: _commonFields.values.describe(
|
||||
'The values to write to the range. This is an array of arrays, where each inner array represents a major dimension (a row or column) of data. (e.g. [["a", "b"], ["c", "d"]])'
|
||||
),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
spreadsheetId: z.string().title('Spreadsheet ID').describe('The spreadsheet the updates were applied to.'),
|
||||
updatedRange: z
|
||||
.string()
|
||||
.title('Updated Range')
|
||||
.describe('The range (in A1 notation) that updates were applied to.'),
|
||||
updatedRows: z
|
||||
.number()
|
||||
.title('Updated Rows')
|
||||
.describe('The number of rows where at least one cell in the row was updated.'),
|
||||
updatedColumns: z
|
||||
.number()
|
||||
.title('Updated Columns')
|
||||
.describe('The number of columns where at least one cell in the column was updated.'),
|
||||
updatedCells: z.number().title('Updated Cells').describe('The number of cells updated.'),
|
||||
}),
|
||||
},
|
||||
} as const satisfies ActionDef
|
||||
|
||||
const appendValues = {
|
||||
title: 'Append Values',
|
||||
description:
|
||||
'Appends values to the spreadsheet. The input startColumn is used to search for existing data and find a "table" within that range. Values will be appended to the next row of the table, starting with the first column of the table.',
|
||||
input: {
|
||||
schema: z.object({
|
||||
sheetName: z
|
||||
.string()
|
||||
.title('Sheet Name')
|
||||
.optional()
|
||||
.describe('The name of the sheet (e.g. "Sheet1"). If not provided, the first visible sheet is used.'),
|
||||
startColumn: z
|
||||
.string()
|
||||
.title('Start Column')
|
||||
.describe(
|
||||
'The start column letter(s) (e.g. "A", "B", "AA"). Used to identify the table in the sheet — data will be appended after the last row of the detected table.'
|
||||
),
|
||||
majorDimension: _commonFields.majorDimension
|
||||
.optional()
|
||||
.describe(
|
||||
'If it equals "ROWS", then the values are inserted as rows. If it equals "COLUMNS", then the values are inserted as columns.'
|
||||
),
|
||||
values: _commonFields.values.describe(
|
||||
'The values to write to the range. This is an array of arrays, where each inner array represents a major dimension (a row or column) of data. (e.g. [["a", "b"], ["c", "d"]])'
|
||||
),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
spreadsheetId: z.string().title('Spreadsheet ID').describe('The spreadsheet the updates were applied to.'),
|
||||
tableRange: z
|
||||
.string()
|
||||
.title('Table Range')
|
||||
.describe(
|
||||
'The range (in A1 notation) of the table that values are being appended to (before the values were appended). Empty if no table was found.'
|
||||
),
|
||||
updates: z
|
||||
.object({
|
||||
spreadsheetId: z.string().title('Spreadsheet ID').describe('The spreadsheet the updates were applied to.'),
|
||||
updatedRange: z
|
||||
.string()
|
||||
.title('Updated Range')
|
||||
.describe('The range (in A1 notation) that updates were applied to.'),
|
||||
updatedRows: z
|
||||
.number()
|
||||
.title('Updated Rows')
|
||||
.describe('The number of rows where at least one cell in the row was updated.'),
|
||||
updatedColumns: z
|
||||
.number()
|
||||
.title('Updated Columns')
|
||||
.describe('The number of columns where at least one cell in the column was updated.'),
|
||||
updatedCells: z.number().title('Updated Cells').describe('The number of cells updated.'),
|
||||
})
|
||||
.title('Updates')
|
||||
.describe('The updates that were applied to the spreadsheet.'),
|
||||
}),
|
||||
},
|
||||
} as const satisfies ActionDef
|
||||
|
||||
const clearValues = {
|
||||
title: 'Clear Values',
|
||||
description:
|
||||
'Clears values from a spreadsheet. Only values are cleared; all other properties of the cell (such as formatting, data validation, etc..) are kept.',
|
||||
input: {
|
||||
schema: z.object({
|
||||
range: _commonFields.range.describe('The A1 notation of the range to clear. (e.g. "Sheet1!A1:B2")'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
spreadsheetId: z.string().title('Spreadsheet ID').describe('The spreadsheet the updates were applied to.'),
|
||||
clearedRange: z.string().title('Cleared Range').describe('The range (in A1 notation) that was cleared.'),
|
||||
}),
|
||||
},
|
||||
} as const satisfies ActionDef
|
||||
|
||||
const getInfoSpreadsheet = {
|
||||
title: 'Get Info of a SpreadSheet',
|
||||
description: 'Returns the properties and metadata of the specified spreadsheet.',
|
||||
input: {
|
||||
schema: z.object({
|
||||
fields: z
|
||||
.array(z.string().title('Field name').describe('The field to include in the response'))
|
||||
.title('Fields')
|
||||
.describe(
|
||||
'The fields to include in the response when retrieving spreadsheet properties and metadata. This is a list of field names. (eg. spreadsheetId, properties.title, sheets.properties.sheetId, sheets.properties.title)'
|
||||
),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
spreadsheetId: z
|
||||
.union([z.string(), z.null()])
|
||||
.title('Spreadsheet ID')
|
||||
.describe('The unique identifier of the spreadsheet.')
|
||||
.optional(),
|
||||
spreadsheetUrl: z
|
||||
.union([z.string(), z.null()])
|
||||
.title('Spreadsheet URL')
|
||||
.describe('The URL of the spreadsheet.')
|
||||
.optional(),
|
||||
dataSources: z
|
||||
.array(z.any())
|
||||
.describe('The data sources connected to the spreadsheet.')
|
||||
.title('Data Sources')
|
||||
.optional(),
|
||||
dataSourceSchedules: z
|
||||
.array(z.any())
|
||||
.describe('The schedules of the data sources.')
|
||||
.title('Data Source Schedules')
|
||||
.optional(),
|
||||
developerMetadata: z
|
||||
.array(z.any())
|
||||
.describe('The developer metadata associated with the spreadsheet.')
|
||||
.title('Developer Metadata')
|
||||
.optional(),
|
||||
namedRanges: z
|
||||
.array(z.any())
|
||||
.describe('The named ranges defined in the spreadsheet.')
|
||||
.title('Named Ranges')
|
||||
.optional(),
|
||||
properties: z.any().describe('The properties of the spreadsheet.').title('Properties').optional(),
|
||||
sheets: z.array(z.any()).describe('The sheets present in the spreadsheet.').title('Sheets').optional(),
|
||||
}),
|
||||
},
|
||||
} as const satisfies ActionDef
|
||||
|
||||
const addSheet = {
|
||||
title: 'Add Sheet',
|
||||
description: 'Adds a new sheet to the spreadsheet.',
|
||||
input: {
|
||||
schema: z.object({
|
||||
title: z.string().title('Title').describe('The title of the new sheet to add to the spreadsheet.'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
spreadsheetId: z.string().title('Spreadsheet ID').describe('The spreadsheet ID of the new sheet.'),
|
||||
newSheet: z
|
||||
.object({
|
||||
sheetId: z.number().title('Sheet ID').describe('The ID of the new sheet.'),
|
||||
title: z.string().title('Title').describe('The title of the new sheet.'),
|
||||
index: z.number().title('Index').describe('The index of the new sheet within the spreadsheet.'),
|
||||
isHidden: z.boolean().title('Is Hidden').describe('Whether the new sheet is hidden.'),
|
||||
})
|
||||
.title('New Sheet')
|
||||
.describe('The new sheet that was added to the spreadsheet.'),
|
||||
}),
|
||||
},
|
||||
} as const satisfies ActionDef
|
||||
|
||||
const deleteSheet = {
|
||||
title: 'Delete Sheet',
|
||||
description: 'Deletes a sheet from the spreadsheet.',
|
||||
input: {
|
||||
schema: z.object({
|
||||
sheetId: z.number().title('Sheet ID').describe('The ID of the sheet to delete.'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({}),
|
||||
},
|
||||
} as const satisfies ActionDef
|
||||
|
||||
const renameSheet = {
|
||||
title: 'Rename Sheet',
|
||||
description: 'Renames a sheet in the spreadsheet.',
|
||||
input: {
|
||||
schema: z.object({
|
||||
sheetId: z.number().title('Sheet ID').describe('The ID of the sheet to rename.'),
|
||||
newTitle: z.string().title('New Title').describe('The new title of the sheet.'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({}),
|
||||
},
|
||||
} as const satisfies ActionDef
|
||||
|
||||
const getAllSheetsInSpreadsheet = {
|
||||
title: 'Get All Sheets in Spreadsheet',
|
||||
description: 'Returns all sheets in the spreadsheet.',
|
||||
input: {
|
||||
schema: z.object({}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
sheets: z
|
||||
.array(
|
||||
z.object({
|
||||
sheetId: z.number().title('Sheet ID').describe('The ID of the sheet.'),
|
||||
title: z.string().title('Title').describe('The name of the sheet.'),
|
||||
index: z.number().title('Index').describe('The index of the sheet within the spreadsheet.'),
|
||||
isHidden: z.boolean().title('Is Hidden').describe('Whether the sheet is hidden.'),
|
||||
hasProtectedRanges: z
|
||||
.boolean()
|
||||
.title('Has Protected Ranges')
|
||||
.describe('Whether the sheet has protected ranges.'),
|
||||
isFullyProtected: z.boolean().title('Is Protected').describe('Whether the entire sheet is protected.'),
|
||||
})
|
||||
)
|
||||
.title('Sheets')
|
||||
.describe('The sheets present in the spreadsheet.'),
|
||||
}),
|
||||
},
|
||||
} as const satisfies ActionDef
|
||||
|
||||
const setSheetVisibility = {
|
||||
title: 'Set Sheet Visibility',
|
||||
description: 'Sets the visibility of a sheet in the spreadsheet.',
|
||||
input: {
|
||||
schema: z.object({
|
||||
sheetId: z.number().title('Sheet ID').describe('The ID of the sheet to set visibility.'),
|
||||
isHidden: z.boolean().title('Is Hidden').describe('Whether the sheet is hidden.'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({}),
|
||||
},
|
||||
} as const satisfies ActionDef
|
||||
|
||||
const moveSheetHorizontally = {
|
||||
title: 'Move Sheet Horizontally',
|
||||
description: 'Moves a sheet to a new index in the spreadsheet.',
|
||||
input: {
|
||||
schema: z.object({
|
||||
sheetId: z.number().title('Sheet ID').describe('The ID of the sheet to move.'),
|
||||
newIndex: z.number().title('New Index').describe('The new index of the sheet within the spreadsheet.'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({}),
|
||||
},
|
||||
} as const satisfies ActionDef
|
||||
|
||||
const protectNamedRange = {
|
||||
title: 'Protect Named Range',
|
||||
description: 'Creates a protected range from a named range, preventing modification.',
|
||||
input: {
|
||||
schema: z.object({
|
||||
namedRangeId: z.string().title('Named Range ID').describe('The ID of the named range to protect.'),
|
||||
warningOnly: z
|
||||
.boolean()
|
||||
.title('Warning Only')
|
||||
.optional()
|
||||
.describe('Whether the protection displays a warning but still allows editing.'),
|
||||
requestingUserCanEdit: z
|
||||
.boolean()
|
||||
.title('Requesting User Can Edit')
|
||||
.optional()
|
||||
.describe('Whether the user adding the protection can edit the protected range.'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
protectedRangeId: z.number().title('Protected Range ID').describe('The ID of the new protected range.'),
|
||||
}),
|
||||
},
|
||||
} as const satisfies ActionDef
|
||||
|
||||
const getNamedRanges = {
|
||||
title: 'Get Named Ranges',
|
||||
description: 'Returns all named ranges in the spreadsheet.',
|
||||
input: {
|
||||
schema: z.object({}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
namedRanges: z
|
||||
.array(
|
||||
z.object({
|
||||
namedRangeId: z.string().title('Named Range ID').describe('The ID of the named range.'),
|
||||
name: z.string().title('Name').describe('The name of the named range.'),
|
||||
range: _commonFields.range.describe('The range of the named range in A1 notation. (e.g. "A1:B2")'),
|
||||
sheetId: z.number().title('Sheet ID').describe('The ID of the sheet the named range applies to.'),
|
||||
})
|
||||
)
|
||||
.title('Named Ranges')
|
||||
.describe('The named ranges defined in the spreadsheet.'),
|
||||
}),
|
||||
},
|
||||
} as const satisfies ActionDef
|
||||
|
||||
const getProtectedRanges = {
|
||||
title: 'Get Protected Ranges',
|
||||
description: 'Returns all protected ranges in the spreadsheet.',
|
||||
input: {
|
||||
schema: z.object({}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
protectedRanges: z
|
||||
.array(
|
||||
z.object({
|
||||
protectedRangeId: z.number().title('Protected Range ID').describe('The ID of the protected range.'),
|
||||
namedRangeId: z
|
||||
.string()
|
||||
.title('Named Range ID')
|
||||
.describe('The ID of the named range, if the protected range is backed by a named range.'),
|
||||
range: _commonFields.range.describe('The range of the protected range in A1 notation. (e.g. "A1:B2")'),
|
||||
sheetId: z.number().title('Sheet ID').describe('The ID of the sheet the protected range applies to.'),
|
||||
description: z.string().title('Description').describe('The description of the protected range.'),
|
||||
warningOnly: z
|
||||
.boolean()
|
||||
.title('Warning Only')
|
||||
.describe('Whether the protection displays a warning but still allows editing.'),
|
||||
requestingUserCanEdit: z
|
||||
.boolean()
|
||||
.title('Requesting User Can Edit')
|
||||
.describe('Whether the user adding the protection can edit the protected range.'),
|
||||
})
|
||||
)
|
||||
.title('Protected Ranges')
|
||||
.describe('The protected ranges defined in the spreadsheet.'),
|
||||
}),
|
||||
},
|
||||
} as const satisfies ActionDef
|
||||
|
||||
const unprotectRange = {
|
||||
title: 'Unprotect Range',
|
||||
description: 'Removes protection from a protected range in the spreadsheet.',
|
||||
input: {
|
||||
schema: z.object({
|
||||
protectedRangeId: z.number().title('Protected Range ID').describe('The ID of the protected range to unprotect.'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({}),
|
||||
},
|
||||
} as const satisfies ActionDef
|
||||
|
||||
const createNamedRangeInSheet = {
|
||||
title: 'Create Named Range in Sheet',
|
||||
description: 'Creates a named range in a sheet.',
|
||||
input: {
|
||||
schema: z.object({
|
||||
sheetId: z.number().title('Sheet ID').describe('The ID of the sheet to create the named range in.'),
|
||||
rangeName: z
|
||||
.string()
|
||||
.title('Name')
|
||||
.describe(
|
||||
'The name of the named range. Must follow naming rules: cannot contain spaces, cannot start with a number, and cannot conflict with standard cell references (e.g., "A1", "R1C1"). Names violating these rules will be considered invalid.'
|
||||
),
|
||||
rangeA1: _commonFields.range.describe(
|
||||
'The A1 notation of the range to associate with the named range. (e.g. "Sheet1!A1:B2")'
|
||||
),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
namedRangeId: z.string().title('Named Range ID').describe('The ID of the new named range.'),
|
||||
}),
|
||||
},
|
||||
} as const satisfies ActionDef
|
||||
|
||||
const _rowSchema = z
|
||||
.object({
|
||||
rowIndex: z.number().title('Row Index').describe('The 1-based index of the row in the sheet.'),
|
||||
values: z.array(z.string().title('Cell Value')).title('Values').describe('The cell values in the row.'),
|
||||
})
|
||||
.title('Row')
|
||||
.describe('A row with its index and values.')
|
||||
|
||||
const findRows = {
|
||||
title: 'Find Rows',
|
||||
description:
|
||||
'Search for rows where a specific column matches a value. Returns all matching rows with their indexes. Handles empty sheets and no matches gracefully by returning an empty array.',
|
||||
input: {
|
||||
schema: z.object({
|
||||
sheetName: z
|
||||
.string()
|
||||
.title('Sheet Name')
|
||||
.optional()
|
||||
.describe('The name of the sheet (e.g. "Sheet1"). If not provided, the first visible sheet is used.'),
|
||||
searchColumn: z.string().title('Search Column').describe('The column letter to search in (e.g. "A", "B", "AA").'),
|
||||
searchValue: z.string().title('Search Value').describe('The value to search for in the specified column.'),
|
||||
dataRange: z
|
||||
.string()
|
||||
.title('Data Range')
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional A1 notation range to limit the search (e.g. "A1:F100"). If not provided, searches the entire sheet.'
|
||||
),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
rows: z.array(_rowSchema).title('Matching Rows').describe('The rows that match the search criteria.'),
|
||||
totalMatches: z.number().title('Total Matches').describe('The total number of matching rows found.'),
|
||||
}),
|
||||
},
|
||||
} as const satisfies ActionDef
|
||||
|
||||
const findRow = {
|
||||
title: 'Find Row (First Match)',
|
||||
description:
|
||||
'Search for the first row where a specific column matches a value. Returns the row data and its index, or null if no match is found.',
|
||||
input: {
|
||||
schema: z.object({
|
||||
sheetName: z
|
||||
.string()
|
||||
.title('Sheet Name')
|
||||
.optional()
|
||||
.describe('The name of the sheet (e.g. "Sheet1"). If not provided, the first visible sheet is used.'),
|
||||
searchColumn: z.string().title('Search Column').describe('The column letter to search in (e.g. "A", "B", "AA").'),
|
||||
searchValue: z.string().title('Search Value').describe('The value to search for in the specified column.'),
|
||||
dataRange: z
|
||||
.string()
|
||||
.title('Data Range')
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional A1 notation range to limit the search (e.g. "A1:F100"). If not provided, searches the entire sheet.'
|
||||
),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
found: z.boolean().title('Found').describe('Whether a matching row was found.'),
|
||||
row: _rowSchema.nullable().describe('The first matching row, or null if not found.'),
|
||||
}),
|
||||
},
|
||||
} as const satisfies ActionDef
|
||||
|
||||
const getRow = {
|
||||
title: 'Get Row',
|
||||
description: 'Fetch a specific row by its 1-based index. Provides direct row access without A1 notation math.',
|
||||
input: {
|
||||
schema: z.object({
|
||||
sheetName: z
|
||||
.string()
|
||||
.title('Sheet Name')
|
||||
.optional()
|
||||
.describe('The name of the sheet (e.g. "Sheet1"). If not provided, the first visible sheet is used.'),
|
||||
rowIndex: z
|
||||
.number()
|
||||
.title('Row Index')
|
||||
.describe('The 1-based row index to retrieve (e.g. 1 for the first row, 2 for the second row).'),
|
||||
startColumn: z
|
||||
.string()
|
||||
.title('Start Column')
|
||||
.optional()
|
||||
.default('A')
|
||||
.describe('The starting column letter (e.g. "A"). Defaults to "A".'),
|
||||
endColumn: z
|
||||
.string()
|
||||
.title('End Column')
|
||||
.optional()
|
||||
.describe('The ending column letter (e.g. "Z"). If not provided, returns all columns with data.'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
found: z.boolean().title('Found').describe('Whether the row exists and has data.'),
|
||||
row: _rowSchema.nullable().describe('The row data, or null if the row is empty or does not exist.'),
|
||||
}),
|
||||
},
|
||||
} as const satisfies ActionDef
|
||||
|
||||
const updateRow = {
|
||||
title: 'Update Row',
|
||||
description:
|
||||
'Update a specific row by its 1-based index with a partial or complete set of values. Only the provided values are updated.',
|
||||
input: {
|
||||
schema: z.object({
|
||||
sheetName: z
|
||||
.string()
|
||||
.title('Sheet Name')
|
||||
.optional()
|
||||
.describe('The name of the sheet (e.g. "Sheet1"). If not provided, the first visible sheet is used.'),
|
||||
rowIndex: z.number().title('Row Index').describe('The 1-based row index to update.'),
|
||||
values: z
|
||||
.array(z.string().title('Cell Value'))
|
||||
.title('Values')
|
||||
.describe('The values to write to the row, starting from the start column.'),
|
||||
startColumn: z
|
||||
.string()
|
||||
.title('Start Column')
|
||||
.optional()
|
||||
.default('A')
|
||||
.describe('The starting column letter for the update (e.g. "A"). Defaults to "A".'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
updatedRange: z.string().title('Updated Range').describe('The range (in A1 notation) that was updated.'),
|
||||
updatedCells: z.number().title('Updated Cells').describe('The number of cells updated.'),
|
||||
}),
|
||||
},
|
||||
} as const satisfies ActionDef
|
||||
|
||||
const insertRowAtIndex = {
|
||||
title: 'Insert Row at Index',
|
||||
description: 'Insert a new row at a specific 1-based index. Existing rows at and below the index are shifted down.',
|
||||
input: {
|
||||
schema: z.object({
|
||||
sheetName: z
|
||||
.string()
|
||||
.title('Sheet Name')
|
||||
.optional()
|
||||
.describe('The name of the sheet (e.g. "Sheet1"). If not provided, the first visible sheet is used.'),
|
||||
rowIndex: z.number().title('Row Index').describe('The 1-based index where the new row should be inserted.'),
|
||||
values: z
|
||||
.array(z.string().title('Cell Value'))
|
||||
.title('Values')
|
||||
.optional()
|
||||
.describe('Optional values to populate the new row.'),
|
||||
startColumn: z
|
||||
.string()
|
||||
.title('Start Column')
|
||||
.optional()
|
||||
.default('A')
|
||||
.describe('The starting column letter for the values (e.g. "A"). Defaults to "A".'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
insertedRowIndex: z.number().title('Inserted Row Index').describe('The 1-based index of the newly inserted row.'),
|
||||
}),
|
||||
},
|
||||
} as const satisfies ActionDef
|
||||
|
||||
const deleteRows = {
|
||||
title: 'Delete Rows',
|
||||
description:
|
||||
'Delete one or more rows by their 1-based indexes. Rows are deleted in reverse order to preserve indexes during deletion.',
|
||||
input: {
|
||||
schema: z.object({
|
||||
sheetName: z
|
||||
.string()
|
||||
.title('Sheet Name')
|
||||
.optional()
|
||||
.describe('The name of the sheet (e.g. "Sheet1"). If not provided, the first visible sheet is used.'),
|
||||
rowIndexes: z
|
||||
.array(z.number().title('Row Index'))
|
||||
.title('Row Indexes')
|
||||
.describe('The 1-based row indexes to delete.'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
deletedCount: z.number().title('Deleted Count').describe('The number of rows deleted.'),
|
||||
}),
|
||||
},
|
||||
} as const satisfies ActionDef
|
||||
|
||||
const upsertRow = {
|
||||
title: 'Upsert Row',
|
||||
description:
|
||||
'Update a row if it exists (based on a key column match), or append a new row if no match is found. Useful for maintaining unique records.',
|
||||
input: {
|
||||
schema: z.object({
|
||||
sheetName: z
|
||||
.string()
|
||||
.title('Sheet Name')
|
||||
.optional()
|
||||
.describe('The name of the sheet (e.g. "Sheet1"). If not provided, the first visible sheet is used.'),
|
||||
keyColumn: z
|
||||
.string()
|
||||
.title('Key Column')
|
||||
.describe('The column letter to use for matching (e.g. "A" for ID column).'),
|
||||
keyValue: z.string().title('Key Value').describe('The value to match in the key column.'),
|
||||
values: z.array(z.string().title('Cell Value')).title('Values').describe('The values to write to the row.'),
|
||||
startColumn: z
|
||||
.string()
|
||||
.title('Start Column')
|
||||
.optional()
|
||||
.default('A')
|
||||
.describe('The starting column letter for the values (e.g. "A"). Defaults to "A".'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
action: z.enum(['updated', 'inserted']).title('Action').describe('Whether the row was updated or inserted.'),
|
||||
rowIndex: z.number().title('Row Index').describe('The 1-based index of the affected row.'),
|
||||
}),
|
||||
},
|
||||
} as const satisfies ActionDef
|
||||
|
||||
export const actions = {
|
||||
addSheet,
|
||||
appendValues,
|
||||
clearValues,
|
||||
createNamedRangeInSheet,
|
||||
deleteRows,
|
||||
deleteSheet,
|
||||
findRow,
|
||||
findRows,
|
||||
getAllSheetsInSpreadsheet,
|
||||
getInfoSpreadsheet,
|
||||
getNamedRanges,
|
||||
getProtectedRanges,
|
||||
getRow,
|
||||
getValues,
|
||||
insertRowAtIndex,
|
||||
moveSheetHorizontally,
|
||||
protectNamedRange,
|
||||
renameSheet,
|
||||
setSheetVisibility,
|
||||
unprotectRange,
|
||||
updateRow,
|
||||
upsertRow,
|
||||
setValues,
|
||||
} as const satisfies ActionDefinitions
|
||||
@@ -0,0 +1,3 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
|
||||
export const channels = {} as const satisfies sdk.IntegrationDefinitionProps['channels']
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
const { z } = sdk
|
||||
|
||||
const _commonConfig = {
|
||||
spreadsheetId: z
|
||||
.string()
|
||||
.title('Spreadsheet ID')
|
||||
.min(1)
|
||||
.describe(
|
||||
'The ID of the Google Spreadsheet to interact with. This is the last part of the URL of your spreadsheet (ex: https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID/edit)'
|
||||
),
|
||||
} as const
|
||||
|
||||
export const configuration = {
|
||||
schema: z.object({}),
|
||||
identifier: { linkTemplateScript: 'linkTemplate.vrl', required: true },
|
||||
} as const satisfies sdk.IntegrationDefinitionProps['configuration']
|
||||
|
||||
export const configurations = {
|
||||
serviceAccountKey: {
|
||||
title: 'Manual configuration',
|
||||
description: 'Configure manually with a Service Account Key',
|
||||
schema: z.object({
|
||||
..._commonConfig,
|
||||
privateKey: z
|
||||
.string()
|
||||
.title('Service account private key')
|
||||
.min(1)
|
||||
.describe('The private key from the Google service account. You can get it from the downloaded JSON file.'),
|
||||
clientEmail: z
|
||||
.string()
|
||||
.title('Service account email')
|
||||
.email()
|
||||
.describe('The client email from the Google service account. You can get it from the downloaded JSON file.'),
|
||||
}),
|
||||
},
|
||||
} as const satisfies sdk.IntegrationDefinitionProps['configurations']
|
||||
|
||||
export const identifier = {} as const satisfies sdk.IntegrationDefinitionProps['identifier']
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
|
||||
/**
|
||||
* Hark! A warning to future developers who dare venture here:
|
||||
*
|
||||
* Know ye this truth: Implementation of webhooks for Google Sheets
|
||||
* is nigh impossible, for unlike its noble sibling the Gmail API,
|
||||
* blessed with its divine `users.watch` endpoint, the Sheets API
|
||||
* provides no such grace to pipe change events into thy Pub/Sub
|
||||
* topic.
|
||||
*
|
||||
* Lo, thy only recourse lies in the realm of the Google Drive API,
|
||||
* which doth permit the tracking of file changes. But beware!
|
||||
* These webhooks are as fleeting as morning mist, expiring after
|
||||
* but four and twenty hours. Moreover, they speak naught of what
|
||||
* hath actually changed within thy sacred spreadsheet.
|
||||
*
|
||||
* Thus art thou condemned to perform the ancient ritual of the
|
||||
* Manual Diff - comparing thy last version with thy current one
|
||||
* each time thy webhook endpoint receives its cryptic call.
|
||||
*
|
||||
* Keep thy coffee chalice full, brave soul, for the path ahead is
|
||||
* dark and treacherous.
|
||||
**/
|
||||
|
||||
export const events = {} as const satisfies sdk.IntegrationDefinitionProps['events']
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from './actions'
|
||||
export * from './channels'
|
||||
export * from './configuration'
|
||||
export * from './events'
|
||||
export * from './secrets'
|
||||
export * from './states'
|
||||
export * from './user-tags'
|
||||
@@ -0,0 +1,9 @@
|
||||
import { posthogHelper } from '@botpress/common'
|
||||
import * as sdk from '@botpress/sdk'
|
||||
|
||||
export const secrets = {
|
||||
...posthogHelper.COMMON_SECRET_NAMES,
|
||||
CLIENT_ID: { description: 'Google OAuth Client ID' },
|
||||
CLIENT_SECRET: { description: 'Google OAuth Client Secret' },
|
||||
FILE_PICKER_API_KEY: { description: 'The API key used to access the Google Picker API' },
|
||||
} as const satisfies sdk.IntegrationDefinitionProps['secrets']
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
const { z } = sdk
|
||||
|
||||
export const states = {
|
||||
oAuthConfig: {
|
||||
type: 'integration',
|
||||
schema: z.object({
|
||||
refreshToken: z
|
||||
.string()
|
||||
.title('Refresh token')
|
||||
.describe('The refresh token to use to authenticate with Google APIs. It gets exchanged for a bearer token'),
|
||||
}),
|
||||
},
|
||||
spreadsheetConfig: {
|
||||
type: 'integration',
|
||||
schema: z.object({
|
||||
spreadsheetId: z
|
||||
.string()
|
||||
.title('Spreadsheet ID')
|
||||
.describe('The ID of the Google Spreadsheet selected during OAuth setup'),
|
||||
}),
|
||||
},
|
||||
} as const satisfies sdk.IntegrationDefinitionProps['states']
|
||||
@@ -0,0 +1,3 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
|
||||
export const user = {} as const satisfies sdk.IntegrationDefinitionProps['user']
|
||||
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,580 @@
|
||||
Supercharge your workflow with the Google Sheets integration, simplifying interactions with your spreadsheet data. Easily update, append, and retrieve values to keep your spreadsheets up-to-date.
|
||||
From tracking inventory and managing project tasks to organizing event attendees, Google Sheets integration streamlines your data management tasks.
|
||||
Give your bot new abilities like add new entries, update existing records, and retrieve essential information.
|
||||
Stay agile and organized by dynamically adding new sheets to accommodate evolving data needs, ensuring your spreadsheets remain flexible and scalable.
|
||||
|
||||
## Important note
|
||||
|
||||
Unfortunately, **automatic configuration is temporarily unavailable**.
|
||||
We are currently in the process of getting our Google Sheets integration verified by Google. Once this verification is complete, you will be able to use the automatic configuration method to set up the Google Sheets integration with just a few clicks. Until then, you will need to create your own Google Cloud Platform (GCP) Service Account by following the steps outlined in the `Manual configuration using a service account` section below.
|
||||
|
||||
## Migrating from 1.x.x to 2.x.x
|
||||
|
||||
Version `2.0.0` of the Google Sheets integration introduces changes to the _Append Values_ action. If you are migrating from a previous version to `2.0.0`, please note the following changes:
|
||||
|
||||
### Changes to the Append Values action
|
||||
|
||||
- The `range` parameter has been replaced with two separate parameters: `sheetName` (optional) and `startColumn` (required).
|
||||
- **Before**: The action accepted a single `range` parameter in A1 notation (e.g., `"Sheet1!A1:B2"`).
|
||||
- **After**: The action now accepts `sheetName` (e.g., `"Sheet1"`) and `startColumn` (e.g., `"A"`) as separate parameters.
|
||||
- The range is now automatically constructed from the start column to row 100,000. The action will search for existing data in this range to find the table and append values after the last row.
|
||||
|
||||
- **Migration guide**: If you were using the old format with a range like `"Sheet1!A1:B2"`, you should now use:
|
||||
|
||||
```json
|
||||
{
|
||||
"sheetName": "Sheet1",
|
||||
"startColumn": "A",
|
||||
"values": [["value1", "value2"]]
|
||||
}
|
||||
```
|
||||
|
||||
If you were using a range without a sheet name like `"A1:B2"`, you can now use:
|
||||
|
||||
```json
|
||||
{
|
||||
"startColumn": "A",
|
||||
"values": [["value1", "value2"]]
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Automatic configuration with OAuth
|
||||
|
||||
To set up the Google Sheets integration using OAuth, click the authorization button and follow the on-screen instructions to connect your Botpress chatbot to Google Sheets.
|
||||
|
||||
When using this configuration mode, a Botpress-managed Google Sheets application will be used to connect to your Google account. However, actions taken by the bot will be attributed to the user who authorized the connection, rather than the application. For this reason, **we do not recommend using personal Google accounts** for this integration. You should set up a service account and use this account to authorize the connection.
|
||||
|
||||
Once the connection is established, you must specify the identifier of the Google Spreadsheet you want to interact with. This identifier is the long string of characters in the URL between `/spreadsheets/d/` and `/edit` when you are editing a spreadsheet.
|
||||
|
||||
> For example, if the URL is `https://docs.google.com/spreadsheets/d/1a2b3c4d5e6f7g8h9i0j/edit`, the identifier of the spreadsheet is `1a2b3c4d5e6f7g8h9i0j`.
|
||||
|
||||
1. Find your Google Spreadsheet ID for the spreadsheet you want to interact with.
|
||||
2. Authorize the Google Sheets integration by clicking the authorization button.
|
||||
3. Fill in the **Spreadsheet ID** field and save the configuration.
|
||||
|
||||
### Manual configuration using a service account
|
||||
|
||||
#### Creating a Google Cloud Platform project
|
||||
|
||||
1. Go to the [Google Cloud Console](https://console.cloud.google.com/).
|
||||
2. Create a new project by clicking the `Select a resource` dropdown in the top navigation bar and selecting `New Project`.
|
||||
3. Follow the on-screen instructions to create the new project.
|
||||
|
||||
#### Enabling the Google Sheets API
|
||||
|
||||
1. In the Google Cloud Console, navigate to the `APIs & Services` section.
|
||||
2. Click on `Library` in the left sidebar.
|
||||
3. Search for `Google Sheets API` and click on the result.
|
||||
4. Click the `Enable` button to enable the Google Sheets API for your project.
|
||||
|
||||
#### Creating a service account
|
||||
|
||||
1. In the Google Cloud Console, navigate to the `IAM & Admin` section.
|
||||
2. Click on `Service Accounts` in the left sidebar.
|
||||
3. Click the `Create service account` button.
|
||||
4. Enter a name for the service account. This should automatically fill the `Service account ID` field.
|
||||
5. Click `Done` to proceed. There is no need to grant any roles or permissions at this stage.
|
||||
|
||||
#### Downloading the service account credentials file
|
||||
|
||||
1. In the Google Cloud Console, navigate to the `IAM & Admin` section.
|
||||
2. Click on `Service Accounts` in the left sidebar.
|
||||
3. Select the service account you created previously.
|
||||
4. Click on the `Keys` tab.
|
||||
5. Click the `Add Key` button and select `JSON`.
|
||||
6. A JSON file containing the service account credentials will be downloaded to your computer. Save this file in a secure location, as it contains sensitive information. You will need this file to configure the Google Sheets integration in Botpress.
|
||||
|
||||
#### Locating your service account email and private key
|
||||
|
||||
1. Open the downloaded JSON file in a text editor.
|
||||
2. Look for the `client_email` field. This is the email address of the service account you created. Copy the email address, excluding the quotation marks. You will need this email address to share your spreadsheet with the service account and to configure the integration in Botpress.
|
||||
3. Look for the `private_key` field. This is the private key associated with the service account. Copy the private key, excluding the quotation marks. You will need this private key to configure the integration in Botpress.
|
||||
> This public key begins with `-----BEGIN PRIVATE KEY-----\n` and ends with `\n-----END PRIVATE KEY-----\n`. You must copy the entire key: everything that is between the quotation marks.
|
||||
|
||||
#### Sharing your spreadsheet with the service account
|
||||
|
||||
1. Open Google Sheets in your web browser.
|
||||
2. Find the spreadsheet you want to access on Botpress and open it.
|
||||
3. Click on the `Share` button in the top right corner of the screen.
|
||||
4. In the dialog window, enter the service account email address you copied earlier in the `Add people` field.
|
||||
5. Give the `Editor` permission to the service account by selecting it from the dropdown menu.
|
||||
6. Click the `Send` button to share the spreadsheet with the service account.
|
||||
|
||||
> **Please note:** your organization may have restrictions on sharing spreadsheets with external users. If you are unable to share the spreadsheet with the service account email address, you may need to use a different account or ask your organization's administrator for help.
|
||||
|
||||
#### Locating your spreadsheet ID
|
||||
|
||||
1. Open Google Sheets in your web browser.
|
||||
2. Find the spreadsheet you want to access on Botpress and open it.
|
||||
3. In the URL of the spreadsheet, you will find the _Spreadsheet ID_. You will need this ID to configure your integration on Botpress. The ID is the long string of characters between `/spreadsheets/d/` and `/edit` in the URL.
|
||||
- For example, if the URL is `https://docs.google.com/spreadsheets/d/1a2b3c4d5e6f7g8h9i0j/edit`, the ID is `1a2b3c4d5e6f7g8h9i0j`.
|
||||
- Copy **only** the ID part of the URL, excluding the `/spreadsheets/d/` and `/edit` parts.
|
||||
|
||||
#### Configuring the Google Sheets integration in Botpress
|
||||
|
||||
1. Install this integration in your bot with the following configuration:
|
||||
- **Spreadsheet ID**: The ID of the Google Spreadsheet to interact with. When editing a spreadsheet, the ID is the long string of characters in the URL between `/spreadsheets/d/` and `/edit`.
|
||||
- **Service account private key**: The private key from the Google service account. You can get it from the downloaded JSON file.
|
||||
- **Service account email**: The client email from the Google service account. You can get it from the downloaded JSON file.
|
||||
|
||||
## Managing several sheets
|
||||
|
||||
While this integration allows you to interact with a single Google Spreadsheet, you can manage multiple sheets within that spreadsheet. To interact with a specific sheet, you must specify the sheet name as part of the range when performing operations.
|
||||
|
||||
The range field uses the same notation as Google Sheets. For example, to interact with a sheet named `Sheet1`, you would use the range `Sheet1!A1:B2`.
|
||||
|
||||
## Key concepts
|
||||
|
||||
### Spreadsheet
|
||||
|
||||
A spreadsheet is the primary object in Google Sheets. It can contain multiple _sheets_, each with structured information contained in _cells_. Each spreadsheet has a unique identifier called the _Spreadsheet ID_.
|
||||
|
||||
### Spreadsheet ID
|
||||
|
||||
The Spreadsheet ID is a unique identifier for a Google _spreadsheet_. It is a long string of characters that can be found in the URL when editing a spreadsheet. The ID is located between `/spreadsheets/d/` and `/edit`.
|
||||
|
||||
### Sheet
|
||||
|
||||
A sheet is a page or tab within a _spreadsheet_ that contains a grid of _cells_. Each sheet has a unique name and can contain data, formulas, and formatting.
|
||||
|
||||
### Range, A1 notation
|
||||
|
||||
The range specifies the sheet and cell range to interact with in the Google Spreadsheet. The range must be given in _A1 notation_, which uses the following format: `SheetName!A1:B2`. The range includes the sheet name followed by an exclamation mark and the cell range.
|
||||
|
||||
While the sheet name is optional, it is recommended to include it to avoid ambiguity when interacting with multiple sheets within the same spreadsheet. If it is omitted, the first visible sheet is used.
|
||||
|
||||
#### A1 notation examples
|
||||
|
||||
- `Sheet1!A1:B2` refers to all the cells in the first two rows and columns of Sheet1.
|
||||
- `Sheet1!A:A` refers to all the cells in the first column of Sheet1.
|
||||
- `Sheet1!1:2` refers to all the cells in the first two rows of Sheet1.
|
||||
- `Sheet1!A5:A` refers to all the cells of the first column of Sheet 1, from row 5 onward.
|
||||
- `A1:B2` refers to all the cells in the first two rows and columns of the first visible sheet.
|
||||
- `Sheet1` refers to all the cells in Sheet1.
|
||||
- `'Mike's_Data'!A1:D5` refers to all the cells in the first five rows and four columns of a sheet named "Mike's_Data."
|
||||
- `'My Custom Sheet'!A:A` refers to all the cells in the first column of a sheet named "My Custom Sheet."
|
||||
- `'My Custom Sheet'` refers to all the cells in "My Custom Sheet".
|
||||
- `MyNamedRange` refers to all the cells in the named range "MyNamedRange".
|
||||
|
||||
> **Please note:** single quotes are required for sheet names with spaces, special characters, or an alphanumeric combination.
|
||||
|
||||
### Major dimension
|
||||
|
||||
The major dimension specifies whether the data is arranged in rows or columns. The major dimension can be either `ROWS` or `COLUMNS`. When performing operations like updating or retrieving values, you can optionally specify the major dimension. If not specified, it defaults to `ROWS`.
|
||||
|
||||
For example, assuming the range `Sheet1!A1:F3` contains the following data:
|
||||
|
||||
| | **A** | **B** | **C** | **D** | **E** | **F** |
|
||||
| ----- | ----- | ----- | ----- | ----- | ----- | ----- |
|
||||
| **1** | 1 | 2 | 3 | 4 | 5 | 6 |
|
||||
| **2** | 7 | 8 | 9 | 10 | 11 | 12 |
|
||||
| **3** | 13 | 14 | 15 | 16 | 17 | 18 |
|
||||
|
||||
If the major dimension is set to `ROWS`, the data will be returned as follows:
|
||||
|
||||
```json
|
||||
{
|
||||
"values": [
|
||||
["1", "2", "3", "4", "5", "6"],
|
||||
["7", "8", "9", "10", "11", "12"],
|
||||
["13", "14", "15", "16", "17", "18"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
If the major dimension is set to `COLUMNS`, the data will be returned as follows:
|
||||
|
||||
```json
|
||||
{
|
||||
"values": [
|
||||
["1", "7", "13"],
|
||||
["2", "8", "14"],
|
||||
["3", "9", "15"],
|
||||
["4", "10", "16"],
|
||||
["5", "11", "17"],
|
||||
["6", "12", "18"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Values
|
||||
|
||||
The values array contains the data retrieved from the Google Spreadsheet. The data is returned as an array of arrays, with each inner array representing a _major dimension_ (a row or column) of data.
|
||||
|
||||
> **Important**: the values are always returned as strings, regardless of the original data type in the spreadsheet. Likewise, when updating values, you must provide the data as strings. Google Sheets will then automatically convert the data to the appropriate type.
|
||||
|
||||
The values array accepts all data types supported by Google Sheets, including text, numbers, dates, and formulas.
|
||||
|
||||
For example, if you want to update the range `Sheet1!A3:A6` with the values `1`, `2`, `3`, and the formula `=SUM(A3:A5)`, you would provide the following data:
|
||||
|
||||
```json
|
||||
{
|
||||
"range": "Sheet1!A3:A6",
|
||||
"majorDimension": "ROWS",
|
||||
"values": [["1", "2", "3", "=SUM(Sheet1!A3:A5)"]]
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Inserting, modifying, and retrieving values from cells
|
||||
|
||||
#### Inserting rows at the end of a table
|
||||
|
||||
To insert a new row of data at the end of a table, you can use the _Append Values_ action. This action appends a new row of data after all other rows of data.
|
||||
|
||||
To use this action, you must specify the start column of the table. The action will search for existing data in that column and find the last row of the table, then append the new data after it.
|
||||
|
||||
For example, if you have a table with the following data in a sheet called `Sheet1`:
|
||||
|
||||
| | **A** | **B** | **C** |
|
||||
| ----- | ------ | ----- | ------ |
|
||||
| **1** | _Name_ | _Age_ | _City_ |
|
||||
| **2** | John | 30 | NY |
|
||||
| **3** | Alice | 25 | LA |
|
||||
|
||||
To append a new row with the data `Mike`, `35`, `SF`, you could use the following configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"sheetName": "Sheet1",
|
||||
"startColumn": "A",
|
||||
"majorDimension": "ROWS",
|
||||
"values": [["Mike", "35", "SF"]]
|
||||
}
|
||||
```
|
||||
|
||||
You can insert multiple rows at once by providing multiple rows of data in the `values` field. For example, to append two new rows with the data `Mike`, `35`, `SF` and `Jane`, `28`, `Chicago`, you could use the following configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"sheetName": "Sheet1",
|
||||
"startColumn": "A",
|
||||
"majorDimension": "ROWS",
|
||||
"values": [
|
||||
["Mike", "35", "SF"],
|
||||
["Jane", "28", "Chicago"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Inserting columns at the end of a table
|
||||
|
||||
If you have a table in which data is arranged in columns instead of rows, you can still use the _Append Values_ action to append new columns of data at the end of the table.
|
||||
|
||||
For example, if you have a table with the following data in a sheet called `Sheet1`:
|
||||
|
||||
| | **A** | **B** | **C** |
|
||||
| ----- | ------ | ----- | ----- |
|
||||
| **1** | _Name_ | John | Alice |
|
||||
| **2** | _Age_ | 30 | 25 |
|
||||
| **3** | _City_ | NY | LA |
|
||||
|
||||
To append a new column with the data `Mike`, `35`, `SF`, you could use the following configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"sheetName": "Sheet1",
|
||||
"startColumn": "A",
|
||||
"majorDimension": "COLUMNS",
|
||||
"values": [["Mike", "35", "SF"]]
|
||||
}
|
||||
```
|
||||
|
||||
The _Major Dimension_ field is the field that determines whether the data is arranged in rows or columns. If you are appending columns, set the _Major Dimension_ field to `COLUMNS`.
|
||||
|
||||
#### Obtaining values from a cell range
|
||||
|
||||
To retrieve values from a cell range, you can use the _Get Values_ action. This action retrieves the values from the specified range in the Google Spreadsheet.
|
||||
|
||||
For example, if you have the following data in a sheet called `Sheet1`:
|
||||
|
||||
| | **A** | **B** | **C** | **D** |
|
||||
| ----- | ----- | ----- | ----- | ----- |
|
||||
| **1** | 1 | 2 | 3 | 4 |
|
||||
| **2** | 5 | 6 | 7 | 8 |
|
||||
| **3** | 9 | 10 | 11 | 12 |
|
||||
|
||||
To retrieve the values from the range `Sheet1!A1:C3`, you could use the following configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"range": "Sheet1!A1:C3",
|
||||
"majorDimension": "ROWS"
|
||||
}
|
||||
```
|
||||
|
||||
The values will be returned in the following format:
|
||||
|
||||
```json
|
||||
{
|
||||
"values": [
|
||||
["1", "2", "3"],
|
||||
["5", "6", "7"],
|
||||
["9", "10", "11"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> If your data is arranged as columns instead of rows, you can set the _Major Dimension_ field to `COLUMNS`.
|
||||
|
||||
#### Updating values in a cell range
|
||||
|
||||
To change values instead of appending new ones, you can use the _Set Values_ action. This action sets the values in the specified range in the Google Spreadsheet.
|
||||
|
||||
For example, if you have the following data in a sheet called `Sheet1`:
|
||||
|
||||
| | **A** | **B** | **C** | **D** |
|
||||
| ----- | ------ | ----- | ------ | -------- |
|
||||
| **1** | _Name_ | _Age_ | _City_ | _Job_ |
|
||||
| **2** | John | 30 | NY | Engineer |
|
||||
| **3** | Alice | 25 | LA | Designer |
|
||||
|
||||
To change Alice's age to `26` and job to `Developer`, you could use the following configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"range": "Sheet1!B3:C3",
|
||||
"majorDimension": "ROWS",
|
||||
"values": [["26", "LA", "Developer"]]
|
||||
}
|
||||
```
|
||||
|
||||
If instead you want to change the city of both John and Alice to `SF`, you could use the following configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"range": "Sheet1!C2:C3",
|
||||
"majorDimension": "ROWS",
|
||||
"values": [["SF"], ["SF"]]
|
||||
}
|
||||
```
|
||||
|
||||
This is equivalent to:
|
||||
|
||||
```json
|
||||
{
|
||||
"range": "Sheet1!C2:C3",
|
||||
"majorDimension": "COLUMNS",
|
||||
"values": [["SF", "SF"]]
|
||||
}
|
||||
```
|
||||
|
||||
### Creating and manipulating sheets
|
||||
|
||||
#### Creating a new sheet
|
||||
|
||||
To create a new sheet in the Google Spreadsheet, you can use the _Add Sheet_ action. This action creates a new sheet with the specified name and places it at the end of the list of sheets.
|
||||
|
||||
#### Obtaining the list of all sheets
|
||||
|
||||
To retrieve the list of all sheets in the Google Spreadsheet, you can use the _Get All Sheets in Spreadsheet_ action. This action returns the names and id of all sheets in the spreadsheet.
|
||||
|
||||
It will return a JSON object with the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"sheets": [
|
||||
{
|
||||
"sheetId": 904893745,
|
||||
"title": "Time sheet",
|
||||
"index": 0,
|
||||
"isHidden": false,
|
||||
"hasProtectedRanges": false,
|
||||
"isFullyProtected": false
|
||||
},
|
||||
{
|
||||
"sheetId": 937004904,
|
||||
"title": "Stats",
|
||||
"index": 1,
|
||||
"isHidden": false,
|
||||
"hasProtectedRanges": true,
|
||||
"isFullyProtected": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Moving a sheet horizontally
|
||||
|
||||
If you want a sheet to appear before or after another sheet, you can use the _Move Sheet Horizontally_ action. This action moves the specified sheet to the specified position in the list of sheets.
|
||||
|
||||
> To use this action, you must first retrieve the id of the sheet you want to move using the _Get All Sheets in Spreadsheet_ action.
|
||||
|
||||
For example, if you want to move the sheet named `Stats` to the first position in the list of sheets, you could use the following configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"sheetId": 937004904,
|
||||
"newIndex": 0
|
||||
}
|
||||
```
|
||||
|
||||
When changing the order of sheets, the new position is based on their current order. For example, if you have three sheets (S1, S2, S3) and you want to move S1 to be after S2, you would set the index to 2. A request to change a sheet's position will be ignored if the new index is the same as the current index or if it is one more than the current index.
|
||||
|
||||
### Working with named ranges
|
||||
|
||||
When working with large or complex spreadsheets, it can be helpful to define named ranges for specific cell ranges. Named ranges provide a convenient way to reference a specific cell range by a meaningful name.
|
||||
|
||||
#### Creating a named range
|
||||
|
||||
To create a new named range in the Google Spreadsheet, you can use the _Create Named Range in Sheet_ action. This action creates a new named range with the specified name and range.
|
||||
|
||||
> To use this action, you must first retrieve the id of the sheet you want to move using the _Get All Sheets in Spreadsheet_ action.
|
||||
|
||||
For example, if you want to create a named range called `MyNamedRange` that refers to the range `Sheet1!A1:B2`, you could use the following configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"sheetId": 937004904, // The id the sheet; not its name
|
||||
"name": "MyNamedRange",
|
||||
"range": "Sheet1!A1:B2"
|
||||
}
|
||||
```
|
||||
|
||||
Once the named range is created, you can reference it by name in other actions that require a range.
|
||||
|
||||
#### Obtaining the list of all named ranges
|
||||
|
||||
To retrieve the list of all named ranges in the Google Spreadsheet, you can use the _Get Named Ranges_ action. This action returns the names, ids, and ranges of all named ranges in the spreadsheet.
|
||||
|
||||
For example, if you have two named ranges in the spreadsheet, `MyRange` and `NamedRange1`, the action would return data similar to the following:
|
||||
|
||||
```json
|
||||
{
|
||||
"namedRanges": [
|
||||
{
|
||||
"namedRangeId": "1001473037",
|
||||
"name": "MyRange",
|
||||
"range": "A1",
|
||||
"sheetId": 206659759
|
||||
},
|
||||
{
|
||||
"namedRangeId": "lkk0nrl90uiy",
|
||||
"name": "NamedRange1",
|
||||
"range": "F28:F32",
|
||||
"sheetId": 937004904
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Working with protected ranges
|
||||
|
||||
Google Sheets allows you to protect specific ranges of cells to prevent them from being edited. Protected ranges can be useful when you want to ensure that certain data remains unchanged.
|
||||
|
||||
#### Protecting a named range
|
||||
|
||||
To created a protected range from a previously-defined named range, you can use the _Protect Named Range_ action. This action creates a protected range from the specified named range.
|
||||
|
||||
> To use this action, you must first retrieve the id of the named range you want to protect using the _Get Named Ranges_ action.
|
||||
|
||||
For example, if you have a named range called `MyNamedRange`, you could use the following configuration to protect this range:
|
||||
|
||||
```json
|
||||
{
|
||||
"namedRangeId": "1001473037",
|
||||
"warningOnly": false,
|
||||
"requestingUserCanEdit": true
|
||||
}
|
||||
```
|
||||
|
||||
In the above example, the `warningOnly` field specifies whether a warning should be displayed when users try to edit the protected range. If this mode is activated, users are still able to edit the range if they dismiss the warning.
|
||||
The `requestingUserCanEdit` field specifies whether the user who requested the protection can edit the protected range, regardless of the `warningOnly` option.
|
||||
|
||||
#### Obtaining the list of all protected ranges
|
||||
|
||||
To retrieve the list of all protected ranges in the Google Spreadsheet, you can use the _Get Protected Ranges_ action. This action returns the ids, ranges, and permissions of all protected ranges in the spreadsheet.
|
||||
|
||||
```json
|
||||
{
|
||||
"protectedRanges": [
|
||||
{
|
||||
"protectedRangeId": 1815142986,
|
||||
"namedRangeId": "",
|
||||
"range": ":",
|
||||
"sheetId": 937004904,
|
||||
"description": "",
|
||||
"warningOnly": true,
|
||||
"requestingUserCanEdit": true
|
||||
},
|
||||
{
|
||||
"protectedRangeId": 640323292,
|
||||
"namedRangeId": "lkk0nrl90uiy",
|
||||
"range": "F28:F32",
|
||||
"sheetId": 937004904,
|
||||
"description": "",
|
||||
"warningOnly": false,
|
||||
"requestingUserCanEdit": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
In the above example, the first protect range is a warning-only range that covers the entire sheet with id `937004904`, while the second protected range is a range that covers the entirety of the _named range_ with id `lkk0nrl90uiy`.
|
||||
|
||||
#### Unprotecting a range
|
||||
|
||||
To remove protection from a previously protected range, you can use the _Unprotect Range_ action. This action removes the protection from the specified range.
|
||||
|
||||
> To use this action, you must first retrieve the id of the protected range you want to unprotect using the _Get Protected Ranges_ action.
|
||||
|
||||
For example, if you have a protected range with the id `1815142986`, you could use the following configuration to unprotect this range:
|
||||
|
||||
```json
|
||||
{
|
||||
"protectedRangeId": 1815142986
|
||||
}
|
||||
```
|
||||
|
||||
### Working with formulas
|
||||
|
||||
When inserting or updating values in a cell range, you can include formulas in the data. Google Sheets will automatically interpret the data as a formula and store it in the cell.
|
||||
Please make sure to include the `=` sign at the beginning of the formula to indicate that it is a formula.
|
||||
|
||||
### Querying metadata
|
||||
|
||||
To obtain metadata about the spreadsheet or its sheets, you can use the _Get Info of a SpreadSheet_ action. In the _Field name_ field, you can specify the metadata you want to retrieve.
|
||||
|
||||
For example, to retrieve the title, locale, and time zone of the spreadsheet, you could use the following configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"fields": ["properties.title", "properties.locale", "properties.timeZone"]
|
||||
}
|
||||
```
|
||||
|
||||
The action will return an object with the requested metadata:
|
||||
|
||||
```json
|
||||
{
|
||||
"properties": {
|
||||
"title": "My Spreadsheet",
|
||||
"locale": "en_US",
|
||||
"timeZone": "America/New_York"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Note: Using wildcards to retrieve all metadata fields is supported, but it is not recommended as it can quickly exhaust your API limits. For instance, you could use `*` to fetch all metadata fields or `sheets.properties.*` to fetch all property fields of all sheets.
|
||||
|
||||
Here are some examples of metadata fields you can query:
|
||||
|
||||
- `properties.title`: The title of the spreadsheet.
|
||||
- `properties.locale`: The locale of the spreadsheet.
|
||||
- `properties.timeZone`: The time zone of the spreadsheet.
|
||||
- `properties.autoRecalc`: The auto-recalculation setting of the spreadsheet.
|
||||
- `properties.defaultFormat`: The default format of the spreadsheet.
|
||||
- `sheets.properties.title`: The title of all sheets.
|
||||
- `sheets.properties.sheetId`: The ID of all sheets.
|
||||
- `sheets.properties.gridProperties.rowCount`: The number of rows in all sheets.
|
||||
- `sheets.properties.gridProperties.columnCount`: The number of columns in all sheets.
|
||||
- `namedRanges.namedRangeId`: The ID of all named ranges.
|
||||
|
||||
## Limitations
|
||||
|
||||
Standard Google Sheets API limitations apply to the Google Sheets integration in Botpress. These limitations include rate limits, payload size restrictions, and other constraints imposed by the Google Cloud platform. Ensure that your chatbot adheres to these limitations to maintain optimal performance and reliability.
|
||||
|
||||
More details are available in the [Google Sheets API documentation](https://developers.google.com/sheets/api/limits).
|
||||
@@ -0,0 +1,55 @@
|
||||
<svg width="74" height="100" viewBox="0 0 74 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0_1:52" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="1" y="1" width="71" height="98">
|
||||
<path d="M45.398 1.43036H7.86688C4.22415 1.43036 1.24374 4.41077 1.24374 8.0535V91.9465C1.24374 95.5893 4.22415 98.5697 7.86688 98.5697H65.2674C68.9101 98.5697 71.8905 95.5893 71.8905 91.9465V27.9229L45.398 1.43036Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_1:52)">
|
||||
<path d="M45.398 1.43036H7.86688C4.22415 1.43036 1.24374 4.41077 1.24374 8.0535V91.9465C1.24374 95.5893 4.22415 98.5697 7.86688 98.5697H65.2674C68.9101 98.5697 71.8905 95.5893 71.8905 91.9465V27.9229L56.4365 16.8843L45.398 1.43036Z" fill="#0F9D58"/>
|
||||
</g>
|
||||
<mask id="mask1_1:52" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="1" y="1" width="71" height="98">
|
||||
<path d="M45.398 1.43036H7.86688C4.22415 1.43036 1.24374 4.41077 1.24374 8.0535V91.9465C1.24374 95.5893 4.22415 98.5697 7.86688 98.5697H65.2674C68.9101 98.5697 71.8905 95.5893 71.8905 91.9465V27.9229L45.398 1.43036Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask1_1:52)">
|
||||
<path d="M18.9054 48.8962V80.908H54.2288V48.8962H18.9054ZM34.3594 76.4926H23.3209V70.9733H34.3594V76.4926ZM34.3594 67.6617H23.3209V62.1424H34.3594V67.6617ZM34.3594 58.8309H23.3209V53.3116H34.3594V58.8309ZM49.8134 76.4926H38.7748V70.9733H49.8134V76.4926ZM49.8134 67.6617H38.7748V62.1424H49.8134V67.6617ZM49.8134 58.8309H38.7748V53.3116H49.8134V58.8309Z" fill="#F1F1F1"/>
|
||||
</g>
|
||||
<mask id="mask2_1:52" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="1" y="1" width="71" height="98">
|
||||
<path d="M45.398 1.43036H7.86688C4.22415 1.43036 1.24374 4.41077 1.24374 8.0535V91.9465C1.24374 95.5893 4.22415 98.5697 7.86688 98.5697H65.2674C68.9101 98.5697 71.8905 95.5893 71.8905 91.9465V27.9229L45.398 1.43036Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask2_1:52)">
|
||||
<path d="M47.3352 25.9856L71.8905 50.5354V27.9229L47.3352 25.9856Z" fill="url(#paint0_linear_1:52)"/>
|
||||
</g>
|
||||
<mask id="mask3_1:52" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="1" y="1" width="71" height="98">
|
||||
<path d="M45.398 1.43036H7.86688C4.22415 1.43036 1.24374 4.41077 1.24374 8.0535V91.9465C1.24374 95.5893 4.22415 98.5697 7.86688 98.5697H65.2674C68.9101 98.5697 71.8905 95.5893 71.8905 91.9465V27.9229L45.398 1.43036Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask3_1:52)">
|
||||
<path d="M45.398 1.43036V21.2998C45.398 24.959 48.3618 27.9229 52.0211 27.9229H71.8905L45.398 1.43036Z" fill="#87CEAC"/>
|
||||
</g>
|
||||
<mask id="mask4_1:52" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="1" y="1" width="71" height="98">
|
||||
<path d="M45.398 1.43036H7.86688C4.22415 1.43036 1.24374 4.41077 1.24374 8.0535V91.9465C1.24374 95.5893 4.22415 98.5697 7.86688 98.5697H65.2674C68.9101 98.5697 71.8905 95.5893 71.8905 91.9465V27.9229L45.398 1.43036Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask4_1:52)">
|
||||
<path d="M7.86688 1.43036C4.22415 1.43036 1.24374 4.41077 1.24374 8.0535V8.60542C1.24374 4.9627 4.22415 1.98229 7.86688 1.98229H45.398V1.43036H7.86688Z" fill="white" fill-opacity="0.2"/>
|
||||
</g>
|
||||
<mask id="mask5_1:52" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="1" y="1" width="71" height="98">
|
||||
<path d="M45.398 1.43036H7.86688C4.22415 1.43036 1.24374 4.41077 1.24374 8.0535V91.9465C1.24374 95.5893 4.22415 98.5697 7.86688 98.5697H65.2674C68.9101 98.5697 71.8905 95.5893 71.8905 91.9465V27.9229L45.398 1.43036Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask5_1:52)">
|
||||
<path d="M65.2674 98.0177H7.86688C4.22415 98.0177 1.24374 95.0373 1.24374 91.3946V91.9465C1.24374 95.5893 4.22415 98.5697 7.86688 98.5697H65.2674C68.9101 98.5697 71.8905 95.5893 71.8905 91.9465V91.3946C71.8905 95.0373 68.9101 98.0177 65.2674 98.0177Z" fill="#263238" fill-opacity="0.2"/>
|
||||
</g>
|
||||
<mask id="mask6_1:52" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="1" y="1" width="71" height="98">
|
||||
<path d="M45.398 1.43036H7.86688C4.22415 1.43036 1.24374 4.41077 1.24374 8.0535V91.9465C1.24374 95.5893 4.22415 98.5697 7.86688 98.5697H65.2674C68.9101 98.5697 71.8905 95.5893 71.8905 91.9465V27.9229L45.398 1.43036Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask6_1:52)">
|
||||
<path d="M52.0211 27.9229C48.3618 27.9229 45.398 24.959 45.398 21.2998V21.8517C45.398 25.511 48.3618 28.4748 52.0211 28.4748H71.8905V27.9229H52.0211Z" fill="#263238" fill-opacity="0.1"/>
|
||||
</g>
|
||||
<path d="M45.398 1.43036H7.86688C4.22415 1.43036 1.24374 4.41077 1.24374 8.0535V91.9465C1.24374 95.5893 4.22415 98.5697 7.86688 98.5697H65.2674C68.9101 98.5697 71.8905 95.5893 71.8905 91.9465V27.9229L45.398 1.43036Z" fill="url(#paint1_radial_1:52)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_1:52" x1="59.6142" y1="28.0935" x2="59.6142" y2="50.5388" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#263238" stop-opacity="0.2"/>
|
||||
<stop offset="1" stop-color="#263238" stop-opacity="0.02"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="paint1_radial_1:52" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(3.48187 3.36121) scale(113.917)">
|
||||
<stop stop-color="white" stop-opacity="0.1"/>
|
||||
<stop offset="1" stop-color="white" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
@@ -0,0 +1,39 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
|
||||
import {
|
||||
actions,
|
||||
channels,
|
||||
configuration,
|
||||
configurations,
|
||||
events,
|
||||
identifier,
|
||||
secrets,
|
||||
states,
|
||||
user,
|
||||
} from './definitions'
|
||||
|
||||
export const INTEGRATION_NAME = 'gsheets'
|
||||
export const INTEGRATION_VERSION = '2.1.10'
|
||||
|
||||
export default new sdk.IntegrationDefinition({
|
||||
name: INTEGRATION_NAME,
|
||||
version: INTEGRATION_VERSION,
|
||||
description: 'Access, update, and append Google Sheets data.',
|
||||
title: 'Google Sheets',
|
||||
readme: 'hub.md',
|
||||
icon: 'icon.svg',
|
||||
actions,
|
||||
channels,
|
||||
configuration,
|
||||
configurations,
|
||||
events,
|
||||
identifier,
|
||||
secrets,
|
||||
states,
|
||||
user,
|
||||
attributes: {
|
||||
category: 'Project Management',
|
||||
guideSlug: 'gsheets',
|
||||
repo: 'botpress',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
webhookId = to_string!(.webhookId)
|
||||
webhookUrl = to_string!(.webhookUrl)
|
||||
|
||||
"{{ webhookUrl }}/oauth/wizard/start?state={{ webhookId }}"
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@botpresshub/gsheets",
|
||||
"scripts": {
|
||||
"check:type": "tsc --noEmit",
|
||||
"check:bplint": "bp lint",
|
||||
"build": "bp add -y && bp build",
|
||||
"test": "vitest --run"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@botpress/client": "workspace:*",
|
||||
"@botpress/common": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"@botpress/sdk-addons": "workspace:*",
|
||||
"googleapis": "^171.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/common": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"preact": "^10.26.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createActionWrapper } from '@botpress/common'
|
||||
import { wrapAsyncFnWithTryCatch } from '../google-api/error-handling'
|
||||
import { GoogleClient } from '../google-api/google-client'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const wrapAction: typeof _wrapAction = (meta, actionImpl) =>
|
||||
_wrapAction(meta, (props) =>
|
||||
wrapAsyncFnWithTryCatch(() => {
|
||||
props.logger
|
||||
.forBot()
|
||||
.debug(`Running action "${meta.actionName}" [bot id: ${props.ctx.botId}]`, { input: props.input })
|
||||
|
||||
return actionImpl(props as Parameters<typeof actionImpl>[0], props.input)
|
||||
}, `Action Error: ${meta.errorMessageWhenFailed}`)()
|
||||
)
|
||||
|
||||
const _wrapAction = createActionWrapper<bp.IntegrationProps>()({
|
||||
toolFactories: {
|
||||
googleClient: GoogleClient.create,
|
||||
},
|
||||
extraMetadata: {} as {
|
||||
errorMessageWhenFailed: string
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import { wrapAction } from '../action-wrapper'
|
||||
|
||||
export const addSheet = wrapAction(
|
||||
{ actionName: 'addSheet', errorMessageWhenFailed: 'Failed to add new sheet' },
|
||||
async ({ googleClient }, { title: sheetTitle }) => await googleClient.createNewSheetInSpreadsheet({ sheetTitle })
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
const _quoteSheetName = (sheetName: string): string => {
|
||||
if (sheetName.startsWith("'") && sheetName.endsWith("'")) {
|
||||
return sheetName
|
||||
}
|
||||
if (/[^A-Za-z0-9_]/.test(sheetName)) {
|
||||
const escaped = sheetName.replace(/'/g, "''")
|
||||
return `'${escaped}'`
|
||||
}
|
||||
return sheetName
|
||||
}
|
||||
|
||||
export const constructRangeFromStartColumn = (sheetName: string | undefined, startColumn: string): string => {
|
||||
const sheetPrefix = sheetName ? `${_quoteSheetName(sheetName)}!` : ''
|
||||
return `${sheetPrefix}${startColumn}:${startColumn}`
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { constructRangeFromStartColumn } from './append-values-utils'
|
||||
|
||||
describe.concurrent('constructRangeFromStartColumn', () => {
|
||||
it('constructs range for simple column without sheet name', () => {
|
||||
expect(constructRangeFromStartColumn(undefined, 'A')).toBe('A:A')
|
||||
expect(constructRangeFromStartColumn(undefined, 'ZZ')).toBe('ZZ:ZZ')
|
||||
})
|
||||
|
||||
it('constructs range for column with sheet name', () => {
|
||||
expect(constructRangeFromStartColumn('Sheet1', 'A')).toBe('Sheet1!A:A')
|
||||
expect(constructRangeFromStartColumn('Sheet1', 'B')).toBe('Sheet1!B:B')
|
||||
expect(constructRangeFromStartColumn('Sheet1', 'AA')).toBe('Sheet1!AA:AA')
|
||||
})
|
||||
|
||||
it('auto-quotes sheet names with spaces', () => {
|
||||
expect(constructRangeFromStartColumn('My Sheet', 'A')).toBe("'My Sheet'!A:A")
|
||||
expect(constructRangeFromStartColumn('Decision Log', 'C')).toBe("'Decision Log'!C:C")
|
||||
})
|
||||
|
||||
it('does not double-quote already quoted sheet names', () => {
|
||||
expect(constructRangeFromStartColumn("'My Sheet'", 'A')).toBe("'My Sheet'!A:A")
|
||||
expect(constructRangeFromStartColumn("'Sheet 1'", 'B')).toBe("'Sheet 1'!B:B")
|
||||
})
|
||||
|
||||
it('quotes sheet names with special characters', () => {
|
||||
expect(constructRangeFromStartColumn('Sheet-1', 'A')).toBe("'Sheet-1'!A:A")
|
||||
expect(constructRangeFromStartColumn('Sheet.1', 'A')).toBe("'Sheet.1'!A:A")
|
||||
})
|
||||
|
||||
it('escapes single quotes (apostrophes) in sheet names', () => {
|
||||
expect(constructRangeFromStartColumn("John's Data", 'A')).toBe("'John''s Data'!A:A")
|
||||
expect(constructRangeFromStartColumn("It's a sheet", 'B')).toBe("'It''s a sheet'!B:B")
|
||||
expect(constructRangeFromStartColumn("A'B'C", 'A')).toBe("'A''B''C'!A:A")
|
||||
})
|
||||
|
||||
it('does not quote simple alphanumeric/underscore sheet names', () => {
|
||||
expect(constructRangeFromStartColumn('Sheet1', 'A')).toBe('Sheet1!A:A')
|
||||
expect(constructRangeFromStartColumn('My_Sheet', 'A')).toBe('My_Sheet!A:A')
|
||||
})
|
||||
|
||||
it('handles multi-character column names', () => {
|
||||
expect(constructRangeFromStartColumn(undefined, 'AB')).toBe('AB:AB')
|
||||
expect(constructRangeFromStartColumn(undefined, 'ABC')).toBe('ABC:ABC')
|
||||
expect(constructRangeFromStartColumn('Sheet1', 'XYZ')).toBe('Sheet1!XYZ:XYZ')
|
||||
})
|
||||
|
||||
it('handles empty string sheet name as undefined', () => {
|
||||
expect(constructRangeFromStartColumn('', 'A')).toBe('A:A')
|
||||
expect(constructRangeFromStartColumn('', 'B')).toBe('B:B')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { wrapAction } from '../action-wrapper'
|
||||
import { constructRangeFromStartColumn } from './append-values-utils'
|
||||
|
||||
export const appendValues = wrapAction(
|
||||
{ actionName: 'appendValues', errorMessageWhenFailed: 'Failed to append values into spreadsheet' },
|
||||
async ({ googleClient }, { sheetName, startColumn, values, majorDimension }) =>
|
||||
await googleClient.appendValuesToSpreadsheetRange({
|
||||
rangeA1: constructRangeFromStartColumn(sheetName, startColumn),
|
||||
values,
|
||||
majorDimension,
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
import { wrapAction } from '../action-wrapper'
|
||||
|
||||
export const clearValues = wrapAction(
|
||||
{ actionName: 'clearValues', errorMessageWhenFailed: 'Failed to clear values from the spreadsheet' },
|
||||
async ({ googleClient }, { range: rangeA1 }) => await googleClient.clearValuesFromSpreadsheetRange({ rangeA1 })
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
import { wrapAction } from '../action-wrapper'
|
||||
|
||||
export const createNamedRangeInSheet = wrapAction(
|
||||
{ actionName: 'createNamedRangeInSheet', errorMessageWhenFailed: 'Failed to add named range to sheet' },
|
||||
async ({ googleClient }, { rangeName, rangeA1, sheetId }) =>
|
||||
await googleClient.createNamedRangeInSheet({ a1Notation: rangeA1, rangeName, sheetId })
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
import { wrapAction } from '../action-wrapper'
|
||||
|
||||
export const deleteRows = wrapAction(
|
||||
{ actionName: 'deleteRows', errorMessageWhenFailed: 'Failed to delete rows' },
|
||||
async ({ googleClient }, { sheetName, rowIndexes }) => {
|
||||
if (rowIndexes.length === 0) {
|
||||
return { deletedCount: 0 }
|
||||
}
|
||||
|
||||
const { sheetId } = await googleClient.getSheetIdByName(sheetName)
|
||||
|
||||
await googleClient.deleteRowsFromSheet({
|
||||
sheetId,
|
||||
rowIndexes,
|
||||
})
|
||||
|
||||
return {
|
||||
deletedCount: rowIndexes.length,
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
import { wrapAction } from '../action-wrapper'
|
||||
|
||||
export const deleteSheet = wrapAction(
|
||||
{ actionName: 'deleteSheet', errorMessageWhenFailed: 'Failed to delete sheet from spreadsheet' },
|
||||
async ({ googleClient }, { sheetId }) => await googleClient.deleteSheetFromSpreadsheet({ sheetId })
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
import { wrapAction } from '../action-wrapper'
|
||||
import { buildSheetPrefix, columnLetterToIndex } from './row-utils'
|
||||
|
||||
export const findRow = wrapAction(
|
||||
{ actionName: 'findRow', errorMessageWhenFailed: 'Failed to find row' },
|
||||
async ({ googleClient }, { sheetName, searchColumn, searchValue, dataRange }) => {
|
||||
const { sheetTitle } = await googleClient.getSheetIdByName(sheetName)
|
||||
const prefix = buildSheetPrefix(sheetTitle)
|
||||
|
||||
const rangeA1 = dataRange ? `${prefix}${dataRange.split('!').pop()}` : `${prefix}A:ZZ`
|
||||
|
||||
let values: string[][] = []
|
||||
try {
|
||||
const result = await googleClient.getValuesFromSpreadsheetRange({ rangeA1, majorDimension: 'ROWS' })
|
||||
values = result.values ?? []
|
||||
} catch {
|
||||
return { found: false, row: null }
|
||||
}
|
||||
|
||||
if (values.length === 0) {
|
||||
return { found: false, row: null }
|
||||
}
|
||||
|
||||
const searchColumnIndex = columnLetterToIndex(searchColumn)
|
||||
|
||||
for (const [i, row] of values.entries()) {
|
||||
const rowValues = row ?? []
|
||||
const cellValue = rowValues[searchColumnIndex] ?? ''
|
||||
|
||||
if (cellValue === searchValue) {
|
||||
return {
|
||||
found: true,
|
||||
row: {
|
||||
rowIndex: i + 1,
|
||||
values: rowValues.map(String),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { found: false, row: null }
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
import { wrapAction } from '../action-wrapper'
|
||||
import { buildSheetPrefix, columnLetterToIndex } from './row-utils'
|
||||
|
||||
export const findRows = wrapAction(
|
||||
{ actionName: 'findRows', errorMessageWhenFailed: 'Failed to find rows' },
|
||||
async ({ googleClient }, { sheetName, searchColumn, searchValue, dataRange }) => {
|
||||
const { sheetTitle } = await googleClient.getSheetIdByName(sheetName)
|
||||
const prefix = buildSheetPrefix(sheetTitle)
|
||||
|
||||
const rangeA1 = dataRange ? `${prefix}${dataRange.split('!').pop()}` : `${prefix}A:ZZ`
|
||||
|
||||
let values: string[][] = []
|
||||
try {
|
||||
const result = await googleClient.getValuesFromSpreadsheetRange({ rangeA1, majorDimension: 'ROWS' })
|
||||
values = result.values ?? []
|
||||
} catch {
|
||||
return { rows: [], totalMatches: 0 }
|
||||
}
|
||||
|
||||
if (values.length === 0) {
|
||||
return { rows: [], totalMatches: 0 }
|
||||
}
|
||||
|
||||
const searchColumnIndex = columnLetterToIndex(searchColumn)
|
||||
const rows: Array<{ rowIndex: number; values: string[] }> = []
|
||||
|
||||
for (const [i, row] of values.entries()) {
|
||||
const rowValues = row ?? []
|
||||
const cellValue = rowValues[searchColumnIndex] ?? ''
|
||||
|
||||
if (cellValue === searchValue) {
|
||||
rows.push({
|
||||
rowIndex: i + 1,
|
||||
values: rowValues.map(String),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rows,
|
||||
totalMatches: rows.length,
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
import { wrapAction } from '../action-wrapper'
|
||||
|
||||
export const getAllSheetsInSpreadsheet = wrapAction(
|
||||
{ actionName: 'getAllSheetsInSpreadsheet', errorMessageWhenFailed: 'Failed to obtain sheets' },
|
||||
async ({ googleClient }) => ({
|
||||
sheets: await googleClient.getAllSheetsInSpreadsheet(),
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
import { wrapAction } from '../action-wrapper'
|
||||
|
||||
export const getInfoSpreadsheet = wrapAction(
|
||||
{ actionName: 'getInfoSpreadsheet', errorMessageWhenFailed: 'Failed to get spreadsheet info' },
|
||||
async ({ googleClient }, { fields }) => await googleClient.getSpreadsheetMetadata({ fields: fields.join(',') })
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
import { wrapAction } from '../action-wrapper'
|
||||
|
||||
export const getNamedRanges = wrapAction(
|
||||
{ actionName: 'getNamedRanges', errorMessageWhenFailed: 'Failed to obtain the named ranges of the spreadsheet' },
|
||||
async ({ googleClient }) => ({
|
||||
namedRanges: await googleClient.getNamedRanges(),
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
import { wrapAction } from '../action-wrapper'
|
||||
|
||||
export const getProtectedRanges = wrapAction(
|
||||
{
|
||||
actionName: 'getProtectedRanges',
|
||||
errorMessageWhenFailed: 'Failed to obtain the protected ranges of the spreadsheet',
|
||||
},
|
||||
async ({ googleClient }) => ({
|
||||
protectedRanges: await googleClient.getProtectedRanges(),
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
import { wrapAction } from '../action-wrapper'
|
||||
import { buildRowRange } from './row-utils'
|
||||
|
||||
export const getRow = wrapAction(
|
||||
{ actionName: 'getRow', errorMessageWhenFailed: 'Failed to get row' },
|
||||
async ({ googleClient }, { sheetName, rowIndex, startColumn, endColumn }) => {
|
||||
const { sheetTitle } = await googleClient.getSheetIdByName(sheetName)
|
||||
|
||||
const rangeA1 = buildRowRange({
|
||||
sheetTitle,
|
||||
rowIndex,
|
||||
startColumn: startColumn ?? 'A',
|
||||
endColumn,
|
||||
})
|
||||
|
||||
let values: string[][] = []
|
||||
try {
|
||||
const result = await googleClient.getValuesFromSpreadsheetRange({ rangeA1, majorDimension: 'ROWS' })
|
||||
values = result.values ?? []
|
||||
} catch {
|
||||
return { found: false, row: null }
|
||||
}
|
||||
|
||||
if (values.length === 0 || !values[0] || values[0].length === 0) {
|
||||
return { found: false, row: null }
|
||||
}
|
||||
|
||||
return {
|
||||
found: true,
|
||||
row: {
|
||||
rowIndex,
|
||||
values: values[0].map(String),
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
import { wrapAction } from '../action-wrapper'
|
||||
|
||||
export const getValues = wrapAction(
|
||||
{ actionName: 'getValues', errorMessageWhenFailed: 'Failed to get values from the specified range' },
|
||||
async ({ googleClient }, { range: rangeA1, majorDimension }) =>
|
||||
await googleClient.getValuesFromSpreadsheetRange({
|
||||
rangeA1,
|
||||
majorDimension,
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
import { wrapAction } from '../action-wrapper'
|
||||
import { buildRowRange, indexToColumnLetter, columnLetterToIndex } from './row-utils'
|
||||
|
||||
export const insertRowAtIndex = wrapAction(
|
||||
{ actionName: 'insertRowAtIndex', errorMessageWhenFailed: 'Failed to insert row at index' },
|
||||
async ({ googleClient }, { sheetName, rowIndex, values, startColumn }) => {
|
||||
const { sheetId, sheetTitle } = await googleClient.getSheetIdByName(sheetName)
|
||||
|
||||
await googleClient.insertRows({
|
||||
sheetId,
|
||||
startIndex: rowIndex - 1,
|
||||
numberOfRows: 1,
|
||||
})
|
||||
|
||||
if (values && values.length > 0) {
|
||||
const start = startColumn ?? 'A'
|
||||
const startColIndex = columnLetterToIndex(start)
|
||||
const endColIndex = startColIndex + values.length - 1
|
||||
const endColumn = indexToColumnLetter(endColIndex)
|
||||
|
||||
const rangeA1 = buildRowRange({
|
||||
sheetTitle,
|
||||
rowIndex,
|
||||
startColumn: start,
|
||||
endColumn,
|
||||
})
|
||||
|
||||
await googleClient.updateValuesInSpreadsheetRange({
|
||||
rangeA1,
|
||||
values: [values],
|
||||
majorDimension: 'ROWS',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
insertedRowIndex: rowIndex,
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
import { wrapAction } from '../action-wrapper'
|
||||
|
||||
export const moveSheetHorizontally = wrapAction(
|
||||
{ actionName: 'moveSheetHorizontally', errorMessageWhenFailed: 'Failed to move sheet to the new index' },
|
||||
async ({ googleClient }, { sheetId, newIndex }) => await googleClient.moveSheetToIndex({ sheetId, newIndex })
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
import { wrapAction } from '../action-wrapper'
|
||||
|
||||
export const protectNamedRange = wrapAction(
|
||||
{ actionName: 'protectNamedRange', errorMessageWhenFailed: 'Failed to protect the named range' },
|
||||
async ({ googleClient }, { namedRangeId, requestingUserCanEdit, warningOnly }) =>
|
||||
await googleClient.protectNamedRange({ namedRangeId, requestingUserCanEdit, warningOnly })
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
import { wrapAction } from '../action-wrapper'
|
||||
|
||||
export const renameSheet = wrapAction(
|
||||
{ actionName: 'renameSheet', errorMessageWhenFailed: 'Failed to rename sheet' },
|
||||
async ({ googleClient }, { sheetId, newTitle }) => await googleClient.renameSheetInSpreadsheet({ sheetId, newTitle })
|
||||
)
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
columnLetterToIndex,
|
||||
indexToColumnLetter,
|
||||
buildSheetPrefix,
|
||||
buildRowRange,
|
||||
buildColumnRange,
|
||||
} from './row-utils'
|
||||
|
||||
describe.concurrent('columnLetterToIndex', () => {
|
||||
it('converts single letters A-Z to indices 0-25', () => {
|
||||
expect(columnLetterToIndex('A')).toBe(0)
|
||||
expect(columnLetterToIndex('B')).toBe(1)
|
||||
expect(columnLetterToIndex('Z')).toBe(25)
|
||||
})
|
||||
|
||||
it('converts double letters starting with AA', () => {
|
||||
expect(columnLetterToIndex('AA')).toBe(26)
|
||||
expect(columnLetterToIndex('AB')).toBe(27)
|
||||
expect(columnLetterToIndex('AZ')).toBe(51)
|
||||
})
|
||||
|
||||
it('converts BA and beyond', () => {
|
||||
expect(columnLetterToIndex('BA')).toBe(52)
|
||||
expect(columnLetterToIndex('ZZ')).toBe(701)
|
||||
})
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
expect(columnLetterToIndex('a')).toBe(0)
|
||||
expect(columnLetterToIndex('aa')).toBe(26)
|
||||
expect(columnLetterToIndex('Aa')).toBe(26)
|
||||
})
|
||||
})
|
||||
|
||||
describe.concurrent('indexToColumnLetter', () => {
|
||||
it('converts indices 0-25 to single letters A-Z', () => {
|
||||
expect(indexToColumnLetter(0)).toBe('A')
|
||||
expect(indexToColumnLetter(1)).toBe('B')
|
||||
expect(indexToColumnLetter(25)).toBe('Z')
|
||||
})
|
||||
|
||||
it('converts index 26+ to double letters', () => {
|
||||
expect(indexToColumnLetter(26)).toBe('AA')
|
||||
expect(indexToColumnLetter(27)).toBe('AB')
|
||||
expect(indexToColumnLetter(51)).toBe('AZ')
|
||||
})
|
||||
|
||||
it('converts higher indices correctly', () => {
|
||||
expect(indexToColumnLetter(52)).toBe('BA')
|
||||
expect(indexToColumnLetter(701)).toBe('ZZ')
|
||||
})
|
||||
})
|
||||
|
||||
describe.concurrent('columnLetterToIndex and indexToColumnLetter roundtrip', () => {
|
||||
it('indexToColumnLetter inverts columnLetterToIndex', () => {
|
||||
const letters = ['A', 'Z', 'AA', 'AZ', 'BA', 'ZZ']
|
||||
for (const letter of letters) {
|
||||
expect(indexToColumnLetter(columnLetterToIndex(letter))).toBe(letter)
|
||||
}
|
||||
})
|
||||
|
||||
it('columnLetterToIndex inverts indexToColumnLetter', () => {
|
||||
const indices = [0, 25, 26, 51, 52, 701]
|
||||
for (const index of indices) {
|
||||
expect(columnLetterToIndex(indexToColumnLetter(index))).toBe(index)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe.concurrent('buildSheetPrefix', () => {
|
||||
it('returns empty string when no sheet title', () => {
|
||||
expect(buildSheetPrefix()).toBe('')
|
||||
expect(buildSheetPrefix(undefined)).toBe('')
|
||||
})
|
||||
|
||||
it('returns simple prefix for alphanumeric titles', () => {
|
||||
expect(buildSheetPrefix('Sheet1')).toBe('Sheet1!')
|
||||
expect(buildSheetPrefix('Data')).toBe('Data!')
|
||||
})
|
||||
|
||||
it('quotes titles containing spaces', () => {
|
||||
expect(buildSheetPrefix('My Sheet')).toBe("'My Sheet'!")
|
||||
})
|
||||
|
||||
it('quotes and escapes titles containing apostrophes', () => {
|
||||
expect(buildSheetPrefix("It's")).toBe("'It''s'!")
|
||||
expect(buildSheetPrefix("Don't stop")).toBe("'Don''t stop'!")
|
||||
})
|
||||
})
|
||||
|
||||
describe.concurrent('buildRowRange', () => {
|
||||
it('builds simple row range with defaults', () => {
|
||||
expect(buildRowRange({ rowIndex: 1 })).toBe('A1:1')
|
||||
})
|
||||
|
||||
it('builds row range with explicit start and end columns', () => {
|
||||
expect(buildRowRange({ rowIndex: 5, startColumn: 'B', endColumn: 'D' })).toBe('B5:D5')
|
||||
})
|
||||
|
||||
it('includes sheet prefix when provided', () => {
|
||||
expect(buildRowRange({ sheetTitle: 'Data', rowIndex: 3, startColumn: 'A', endColumn: 'C' })).toBe('Data!A3:C3')
|
||||
})
|
||||
|
||||
it('handles sheet titles requiring quotes', () => {
|
||||
expect(buildRowRange({ sheetTitle: 'My Data', rowIndex: 1, endColumn: 'B' })).toBe("'My Data'!A1:B1")
|
||||
})
|
||||
})
|
||||
|
||||
describe.concurrent('buildColumnRange', () => {
|
||||
it('builds single column range with defaults', () => {
|
||||
expect(buildColumnRange({})).toBe('A1:A100000')
|
||||
})
|
||||
|
||||
it('builds range for specified columns', () => {
|
||||
expect(buildColumnRange({ startColumn: 'B', endColumn: 'D' })).toBe('B1:D100000')
|
||||
})
|
||||
|
||||
it('uses startColumn as endColumn when endColumn not specified', () => {
|
||||
expect(buildColumnRange({ startColumn: 'C' })).toBe('C1:C100000')
|
||||
})
|
||||
|
||||
it('respects custom maxRow', () => {
|
||||
expect(buildColumnRange({ startColumn: 'A', endColumn: 'B', maxRow: 500 })).toBe('A1:B500')
|
||||
})
|
||||
|
||||
it('includes sheet prefix when provided', () => {
|
||||
expect(buildColumnRange({ sheetTitle: 'Sales', startColumn: 'A', endColumn: 'C' })).toBe('Sales!A1:C100000')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
const ASCII_UPPERCASE_A = 65
|
||||
const BASE_26 = 26
|
||||
|
||||
export const columnLetterToIndex = (col: string): number =>
|
||||
col
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.reduce((acc, char) => acc * BASE_26 + char.charCodeAt(0) - ASCII_UPPERCASE_A + 1, 0) - 1
|
||||
|
||||
export const indexToColumnLetter = (index: number): string => {
|
||||
let letter = ''
|
||||
let i = index
|
||||
while (i >= 0) {
|
||||
letter = String.fromCharCode((i % BASE_26) + ASCII_UPPERCASE_A) + letter
|
||||
i = Math.floor(i / BASE_26) - 1
|
||||
}
|
||||
return letter
|
||||
}
|
||||
|
||||
export const buildSheetPrefix = (sheetTitle?: string): string => {
|
||||
if (!sheetTitle) {
|
||||
return ''
|
||||
}
|
||||
const needsQuotes = sheetTitle.includes(' ') || sheetTitle.includes("'")
|
||||
return needsQuotes ? `'${sheetTitle.replace(/'/g, "''")}'!` : `${sheetTitle}!`
|
||||
}
|
||||
|
||||
export const buildRowRange = ({
|
||||
sheetTitle,
|
||||
rowIndex,
|
||||
startColumn = 'A',
|
||||
endColumn,
|
||||
}: {
|
||||
sheetTitle?: string
|
||||
rowIndex: number
|
||||
startColumn?: string
|
||||
endColumn?: string
|
||||
}): string => {
|
||||
const prefix = buildSheetPrefix(sheetTitle)
|
||||
const end = endColumn ? `${endColumn}${rowIndex}` : `${rowIndex}`
|
||||
return `${prefix}${startColumn}${rowIndex}:${end}`
|
||||
}
|
||||
|
||||
export const buildColumnRange = ({
|
||||
sheetTitle,
|
||||
startColumn = 'A',
|
||||
endColumn,
|
||||
maxRow = 100000,
|
||||
}: {
|
||||
sheetTitle?: string
|
||||
startColumn?: string
|
||||
endColumn?: string
|
||||
maxRow?: number
|
||||
}): string => {
|
||||
const prefix = buildSheetPrefix(sheetTitle)
|
||||
const end = endColumn ?? startColumn
|
||||
return `${prefix}${startColumn}1:${end}${maxRow}`
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { wrapAction } from '../action-wrapper'
|
||||
|
||||
export const setSheetVisibility = wrapAction(
|
||||
{ actionName: 'setSheetVisibility', errorMessageWhenFailed: 'Failed to set sheet visibility' },
|
||||
async ({ googleClient }, { sheetId, isHidden }) => await googleClient.setSheetVisibility({ sheetId, isHidden })
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
import { wrapAction } from '../action-wrapper'
|
||||
|
||||
export const setValues = wrapAction(
|
||||
{ actionName: 'setValues', errorMessageWhenFailed: 'Failed to set values in the specified range' },
|
||||
async ({ googleClient }, { range: rangeA1, majorDimension, values }) =>
|
||||
await googleClient.updateValuesInSpreadsheetRange({
|
||||
rangeA1,
|
||||
majorDimension,
|
||||
values,
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
import { wrapAction } from '../action-wrapper'
|
||||
|
||||
export const unprotectRange = wrapAction(
|
||||
{ actionName: 'unprotectRange', errorMessageWhenFailed: 'Failed to delete protected range' },
|
||||
async ({ googleClient }, { protectedRangeId }) => await googleClient.unprotectRange({ protectedRangeId })
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
import { wrapAction } from '../action-wrapper'
|
||||
import { buildRowRange, indexToColumnLetter, columnLetterToIndex } from './row-utils'
|
||||
|
||||
export const updateRow = wrapAction(
|
||||
{ actionName: 'updateRow', errorMessageWhenFailed: 'Failed to update row' },
|
||||
async ({ googleClient }, { sheetName, rowIndex, values, startColumn }) => {
|
||||
const { sheetTitle } = await googleClient.getSheetIdByName(sheetName)
|
||||
|
||||
const start = startColumn ?? 'A'
|
||||
const startColIndex = columnLetterToIndex(start)
|
||||
const endColIndex = startColIndex + values.length - 1
|
||||
const endColumn = indexToColumnLetter(endColIndex)
|
||||
|
||||
const rangeA1 = buildRowRange({
|
||||
sheetTitle,
|
||||
rowIndex,
|
||||
startColumn: start,
|
||||
endColumn,
|
||||
})
|
||||
|
||||
const result = await googleClient.updateValuesInSpreadsheetRange({
|
||||
rangeA1,
|
||||
values: [values],
|
||||
majorDimension: 'ROWS',
|
||||
})
|
||||
|
||||
return {
|
||||
updatedRange: result.updatedRange,
|
||||
updatedCells: result.updatedCells,
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
import { wrapAction } from '../action-wrapper'
|
||||
import { buildSheetPrefix, buildRowRange, columnLetterToIndex, indexToColumnLetter } from './row-utils'
|
||||
|
||||
export const upsertRow = wrapAction(
|
||||
{ actionName: 'upsertRow', errorMessageWhenFailed: 'Failed to upsert row' },
|
||||
async ({ googleClient }, { sheetName, keyColumn, keyValue, values, startColumn }) => {
|
||||
const { sheetTitle } = await googleClient.getSheetIdByName(sheetName)
|
||||
const prefix = buildSheetPrefix(sheetTitle)
|
||||
|
||||
const rangeA1 = `${prefix}A:ZZ`
|
||||
|
||||
let existingValues: string[][] = []
|
||||
try {
|
||||
const result = await googleClient.getValuesFromSpreadsheetRange({ rangeA1, majorDimension: 'ROWS' })
|
||||
existingValues = result.values ?? []
|
||||
} catch {
|
||||
existingValues = []
|
||||
}
|
||||
|
||||
const keyColumnIndex = columnLetterToIndex(keyColumn)
|
||||
let matchingRowIndex: number | null = null
|
||||
|
||||
for (const [i, row] of existingValues.entries()) {
|
||||
const rowValues = row ?? []
|
||||
const cellValue = rowValues[keyColumnIndex] ?? ''
|
||||
|
||||
if (cellValue === keyValue) {
|
||||
matchingRowIndex = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const start = startColumn ?? 'A'
|
||||
const startColIndex = columnLetterToIndex(start)
|
||||
const endColIndex = startColIndex + values.length - 1
|
||||
const endColumn = indexToColumnLetter(endColIndex)
|
||||
|
||||
if (matchingRowIndex !== null) {
|
||||
const updateRangeA1 = buildRowRange({
|
||||
sheetTitle,
|
||||
rowIndex: matchingRowIndex,
|
||||
startColumn: start,
|
||||
endColumn,
|
||||
})
|
||||
|
||||
await googleClient.updateValuesInSpreadsheetRange({
|
||||
rangeA1: updateRangeA1,
|
||||
values: [values],
|
||||
majorDimension: 'ROWS',
|
||||
})
|
||||
|
||||
return {
|
||||
action: 'updated' as const,
|
||||
rowIndex: matchingRowIndex,
|
||||
}
|
||||
}
|
||||
|
||||
const appendRangeA1 = `${prefix}${start}:${endColumn}`
|
||||
|
||||
const appendResult = await googleClient.appendValuesToSpreadsheetRange({
|
||||
rangeA1: appendRangeA1,
|
||||
values: [values],
|
||||
majorDimension: 'ROWS',
|
||||
})
|
||||
|
||||
const updatedRange = appendResult.updates.updatedRange
|
||||
const rowMatch = updatedRange.match(/:?[A-Z]+(\d+)$/)
|
||||
const insertedRowIndex = rowMatch?.[1] ? parseInt(rowMatch[1], 10) : existingValues.length + 1
|
||||
|
||||
return {
|
||||
action: 'inserted' as const,
|
||||
rowIndex: insertedRowIndex,
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
import { addSheet } from './implementations/add-sheet'
|
||||
import { appendValues } from './implementations/append-values'
|
||||
import { clearValues } from './implementations/clear-values'
|
||||
import { createNamedRangeInSheet } from './implementations/create-named-range-in-sheet'
|
||||
import { deleteRows } from './implementations/delete-rows'
|
||||
import { deleteSheet } from './implementations/delete-sheet'
|
||||
import { findRow } from './implementations/find-row'
|
||||
import { findRows } from './implementations/find-rows'
|
||||
import { getAllSheetsInSpreadsheet } from './implementations/get-all-sheets-in-spreadsheet'
|
||||
import { getInfoSpreadsheet } from './implementations/get-info-spread-sheet'
|
||||
import { getNamedRanges } from './implementations/get-named-ranges'
|
||||
import { getProtectedRanges } from './implementations/get-protected-ranges'
|
||||
import { getRow } from './implementations/get-row'
|
||||
import { getValues } from './implementations/get-values'
|
||||
import { insertRowAtIndex } from './implementations/insert-row-at-index'
|
||||
import { moveSheetHorizontally } from './implementations/move-sheet-horizontally'
|
||||
import { protectNamedRange } from './implementations/protect-named-range'
|
||||
import { renameSheet } from './implementations/rename-sheet'
|
||||
import { setSheetVisibility } from './implementations/set-sheet-visibility'
|
||||
import { setValues } from './implementations/set-values'
|
||||
import { unprotectRange } from './implementations/unprotect-range'
|
||||
import { updateRow } from './implementations/update-row'
|
||||
import { upsertRow } from './implementations/upsert-row'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export default {
|
||||
addSheet,
|
||||
appendValues,
|
||||
clearValues,
|
||||
createNamedRangeInSheet,
|
||||
deleteRows,
|
||||
deleteSheet,
|
||||
findRow,
|
||||
findRows,
|
||||
getAllSheetsInSpreadsheet,
|
||||
getInfoSpreadsheet,
|
||||
getNamedRanges,
|
||||
getProtectedRanges,
|
||||
getRow,
|
||||
getValues,
|
||||
insertRowAtIndex,
|
||||
moveSheetHorizontally,
|
||||
protectNamedRange,
|
||||
renameSheet,
|
||||
setSheetVisibility,
|
||||
setValues,
|
||||
unprotectRange,
|
||||
updateRow,
|
||||
upsertRow,
|
||||
} as const satisfies bp.IntegrationProps['actions']
|
||||
@@ -0,0 +1 @@
|
||||
export * from './publisher-dispatcher'
|
||||
@@ -0,0 +1,3 @@
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const channels: bp.IntegrationProps['channels'] = async () => {}
|
||||
@@ -0,0 +1,82 @@
|
||||
// a1-converter.test.ts
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { A1Converter } from './a1-converter'
|
||||
|
||||
describe.concurrent('A1Converter', () => {
|
||||
it('converts empty range to ":"', () => {
|
||||
const range = {}
|
||||
expect(A1Converter.gridRangeToA1(range)).toBe(':')
|
||||
})
|
||||
|
||||
it('converts single cell range', () => {
|
||||
const range = {
|
||||
startColumnIndex: 0,
|
||||
startRowIndex: 0,
|
||||
endColumnIndex: 1,
|
||||
endRowIndex: 1,
|
||||
}
|
||||
expect(A1Converter.gridRangeToA1(range)).toBe('A1')
|
||||
})
|
||||
|
||||
it('converts multi-cell range', () => {
|
||||
const range = {
|
||||
startColumnIndex: 0,
|
||||
startRowIndex: 0,
|
||||
endColumnIndex: 2,
|
||||
endRowIndex: 2,
|
||||
}
|
||||
expect(A1Converter.gridRangeToA1(range)).toBe('A1:B2')
|
||||
})
|
||||
|
||||
it('handles unbounded start range', () => {
|
||||
const range = {
|
||||
endColumnIndex: 2,
|
||||
endRowIndex: 2,
|
||||
}
|
||||
expect(A1Converter.gridRangeToA1(range)).toBe(':B2')
|
||||
})
|
||||
|
||||
it('handles unbounded end range', () => {
|
||||
const range = {
|
||||
startColumnIndex: 0,
|
||||
startRowIndex: 0,
|
||||
}
|
||||
expect(A1Converter.gridRangeToA1(range)).toBe('A1:')
|
||||
})
|
||||
|
||||
it('converts columns beyond Z correctly', () => {
|
||||
const range = {
|
||||
startColumnIndex: 26,
|
||||
startRowIndex: 0,
|
||||
endColumnIndex: 28,
|
||||
endRowIndex: 2,
|
||||
}
|
||||
expect(A1Converter.gridRangeToA1(range)).toBe('AA1:AB2')
|
||||
})
|
||||
|
||||
it('handles null values', () => {
|
||||
const range = {
|
||||
startColumnIndex: null,
|
||||
startRowIndex: null,
|
||||
endColumnIndex: null,
|
||||
endRowIndex: null,
|
||||
}
|
||||
expect(A1Converter.gridRangeToA1(range)).toBe(':')
|
||||
})
|
||||
|
||||
it('handles large column indices', () => {
|
||||
const range = {
|
||||
startColumnIndex: 701, // Should be 'ZZ'
|
||||
startRowIndex: 0,
|
||||
}
|
||||
expect(A1Converter.gridRangeToA1(range)).toBe('ZZ1:')
|
||||
})
|
||||
|
||||
it('handles large row indices', () => {
|
||||
const range = {
|
||||
startColumnIndex: 0,
|
||||
startRowIndex: 999,
|
||||
}
|
||||
expect(A1Converter.gridRangeToA1(range)).toBe('A1000:')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { sheets_v4 } from 'googleapis'
|
||||
type GridRange = sheets_v4.Schema$GridRange
|
||||
|
||||
export namespace A1Converter {
|
||||
const ASCII_UPPERCASE_A = 65
|
||||
const BASE_26 = 26
|
||||
const COLUMN_INDEX_OFFSET = 1
|
||||
const ROW_INDEX_OFFSET = 1
|
||||
|
||||
export const gridRangeToA1 = (range: GridRange): string => {
|
||||
if (_isEmptyRange(range)) {
|
||||
return ':'
|
||||
}
|
||||
|
||||
const start = _getStartReference(range)
|
||||
const end = _getEndReference(range)
|
||||
|
||||
return start === end ? start : `${start}:${end}`
|
||||
}
|
||||
|
||||
const _isEmptyRange = (range: GridRange): boolean =>
|
||||
range.startColumnIndex == null &&
|
||||
range.startRowIndex == null &&
|
||||
range.endColumnIndex == null &&
|
||||
range.endRowIndex == null
|
||||
|
||||
const _getStartReference = (range: GridRange): string =>
|
||||
_buildCellReference(range.startColumnIndex, range.startRowIndex)
|
||||
|
||||
const _getEndReference = (range: GridRange): string =>
|
||||
_buildCellReference(
|
||||
range.endColumnIndex ? range.endColumnIndex - COLUMN_INDEX_OFFSET : undefined,
|
||||
range.endRowIndex ? range.endRowIndex - ROW_INDEX_OFFSET : undefined
|
||||
)
|
||||
|
||||
const _buildCellReference = (columnIndex?: number | null, rowIndex?: number | null): string => {
|
||||
const columnRef = typeof columnIndex === 'number' ? _columnIndexToLetter(columnIndex) : ''
|
||||
const rowRef = typeof rowIndex === 'number' ? (rowIndex + ROW_INDEX_OFFSET).toString() : ''
|
||||
|
||||
return `${columnRef}${rowRef}`
|
||||
}
|
||||
|
||||
const _columnIndexToLetter = (index: number): string => {
|
||||
let letter = ''
|
||||
while (index >= 0) {
|
||||
letter = String.fromCharCode((index % BASE_26) + ASCII_UPPERCASE_A) + letter
|
||||
index = Math.floor(index / BASE_26) - COLUMN_INDEX_OFFSET
|
||||
}
|
||||
return letter
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { A1NotationParser } from './a1-parser'
|
||||
|
||||
describe.concurrent('A1NotationParser', () => {
|
||||
const validCases = [
|
||||
[
|
||||
'Sheet1!A1:B2',
|
||||
{
|
||||
startRowIndex: 0,
|
||||
endRowIndex: 2,
|
||||
startColumnIndex: 0,
|
||||
endColumnIndex: 2,
|
||||
},
|
||||
],
|
||||
[
|
||||
'Sheet1!A:A',
|
||||
{
|
||||
startColumnIndex: 0,
|
||||
endColumnIndex: 1,
|
||||
},
|
||||
],
|
||||
[
|
||||
'Sheet1!1:2',
|
||||
{
|
||||
startRowIndex: 0,
|
||||
endRowIndex: 2,
|
||||
},
|
||||
],
|
||||
[
|
||||
'Sheet1!A5:A',
|
||||
{
|
||||
startRowIndex: 4,
|
||||
startColumnIndex: 0,
|
||||
endColumnIndex: 1,
|
||||
},
|
||||
],
|
||||
[
|
||||
'A1',
|
||||
{
|
||||
startRowIndex: 0,
|
||||
endRowIndex: 1,
|
||||
startColumnIndex: 0,
|
||||
endColumnIndex: 1,
|
||||
},
|
||||
],
|
||||
[
|
||||
'B2:E9',
|
||||
{
|
||||
startRowIndex: 1,
|
||||
endRowIndex: 9,
|
||||
startColumnIndex: 1,
|
||||
endColumnIndex: 5,
|
||||
},
|
||||
],
|
||||
[
|
||||
'AA1:ZZ2',
|
||||
{
|
||||
startRowIndex: 0,
|
||||
endRowIndex: 2,
|
||||
startColumnIndex: 26,
|
||||
endColumnIndex: 702,
|
||||
},
|
||||
],
|
||||
] as const
|
||||
|
||||
it.each(validCases)('correctly parses %s', (input, expected) => {
|
||||
expect(A1NotationParser.parse(input)).toEqual(expected)
|
||||
})
|
||||
|
||||
const errorCases = [
|
||||
['', 'Range cannot be empty'],
|
||||
['!', 'Range part cannot be empty'],
|
||||
['Sheet1!', 'Range part cannot be empty'],
|
||||
['Sheet1!:', 'Start cell reference cannot be empty'],
|
||||
[':', 'Start cell reference cannot be empty'],
|
||||
['Sheet1!@#$', 'Invalid cell format: @#$'],
|
||||
['A1:@#$', 'Invalid cell format: @#$'],
|
||||
['@#$', 'Invalid cell format: @#$'],
|
||||
] as const
|
||||
|
||||
it.each(errorCases)('throws on %s', (input, expectedError) => {
|
||||
expect(() => A1NotationParser.parse(input)).toThrowError(expectedError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
|
||||
export type RangeIndices = {
|
||||
startRowIndex?: number
|
||||
endRowIndex?: number
|
||||
startColumnIndex?: number
|
||||
endColumnIndex?: number
|
||||
}
|
||||
|
||||
export class InvalidA1NotationError extends sdk.RuntimeError {
|
||||
public constructor(message: string) {
|
||||
super(`Error while parsing A1 notation: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export namespace A1NotationParser {
|
||||
const COLUMN_PATTERN = /^[A-Za-z]+$/
|
||||
const ROW_PATTERN = /^\d+$/
|
||||
const CELL_PATTERN = /^([A-Za-z]*)(\d*)$/
|
||||
const BASE_26 = 26
|
||||
const ASCII_UPPERCASE_A = 65
|
||||
const COLUMN_INDEX_OFFSET = 1
|
||||
const ROW_INDEX_OFFSET = 1
|
||||
|
||||
type Cell = {
|
||||
row: number | null
|
||||
col: number | null
|
||||
}
|
||||
|
||||
export const parse = (range: string): RangeIndices => {
|
||||
_assertRangeNotEmpty(range)
|
||||
|
||||
const rangePart = range.split('!').pop()
|
||||
_assertRangePartNotEmpty(rangePart)
|
||||
|
||||
const [start, end = ''] = rangePart.split(':')
|
||||
_assertStartNotEmpty(start)
|
||||
|
||||
const startCell = _parseCell(start)
|
||||
_assertValidCellReference(startCell, start)
|
||||
|
||||
const endCell = end ? _parseCell(end) : startCell
|
||||
if (end) {
|
||||
_assertValidCellReference(endCell, end)
|
||||
}
|
||||
|
||||
return _applyRangeRules(start, end, _createRanges(startCell, endCell))
|
||||
}
|
||||
|
||||
const _assertRangeNotEmpty = (range?: string) => {
|
||||
if (!range) {
|
||||
throw new InvalidA1NotationError('Range cannot be empty')
|
||||
}
|
||||
}
|
||||
|
||||
const _assertRangePartNotEmpty: (rangePart?: string) => asserts rangePart is string = (rangePart) => {
|
||||
if (!rangePart) {
|
||||
throw new InvalidA1NotationError('Range part cannot be empty')
|
||||
}
|
||||
}
|
||||
|
||||
const _assertStartNotEmpty: (start?: string) => asserts start is string = (start) => {
|
||||
if (!start) {
|
||||
throw new InvalidA1NotationError('Start cell reference cannot be empty')
|
||||
}
|
||||
}
|
||||
|
||||
const _assertValidCellReference = (cell: Cell, reference: string) => {
|
||||
if (cell.row === null && cell.col === null) {
|
||||
throw new InvalidA1NotationError(`Invalid cell reference: ${reference}`)
|
||||
}
|
||||
}
|
||||
|
||||
const _createRanges = (start: Cell, end: Cell): RangeIndices => ({
|
||||
..._createColumnRanges(start, end),
|
||||
..._createRowRanges(start, end),
|
||||
})
|
||||
|
||||
const _createColumnRanges = ({ col: startCol }: Cell, { col: endCol }: Cell): RangeIndices =>
|
||||
startCol === null ? {} : { startColumnIndex: startCol, endColumnIndex: (endCol ?? startCol) + COLUMN_INDEX_OFFSET }
|
||||
|
||||
const _createRowRanges = ({ row: startRow }: Cell, { row: endRow }: Cell): RangeIndices =>
|
||||
startRow === null ? {} : { startRowIndex: startRow, endRowIndex: (endRow ?? startRow) + ROW_INDEX_OFFSET }
|
||||
|
||||
const _applyRangeRules = (start: string, end: string, ranges: RangeIndices): RangeIndices =>
|
||||
!end
|
||||
? ranges
|
||||
: _isColumnOnlyRange(start, end)
|
||||
? _extractColumnRange(ranges)
|
||||
: _isRowOnlyRange(start, end)
|
||||
? _extractRowRange(ranges)
|
||||
: _applyOpenEndedRule(end, ranges)
|
||||
|
||||
const _isColumnOnlyRange = (start: string, end: string): boolean =>
|
||||
COLUMN_PATTERN.test(start) && COLUMN_PATTERN.test(end)
|
||||
|
||||
const _isRowOnlyRange = (start: string, end: string): boolean => ROW_PATTERN.test(start) && ROW_PATTERN.test(end)
|
||||
|
||||
const _extractColumnRange = ({ startColumnIndex, endColumnIndex }: RangeIndices): RangeIndices => ({
|
||||
startColumnIndex,
|
||||
endColumnIndex,
|
||||
})
|
||||
|
||||
const _extractRowRange = ({ startRowIndex, endRowIndex }: RangeIndices): RangeIndices => ({
|
||||
startRowIndex,
|
||||
endRowIndex,
|
||||
})
|
||||
|
||||
const _applyOpenEndedRule = (end: string, ranges: RangeIndices): RangeIndices =>
|
||||
COLUMN_PATTERN.test(end) ? { ...ranges, endRowIndex: undefined } : ranges
|
||||
|
||||
const _parseCell = (cell: string): Cell => {
|
||||
const match = CELL_PATTERN.exec(cell)
|
||||
_assertValidCellFormat(match, cell)
|
||||
|
||||
const [, colPart, rowPart] = match
|
||||
return {
|
||||
row: rowPart ? +rowPart - ROW_INDEX_OFFSET : null,
|
||||
col: colPart ? _columnToIndex(colPart) : null,
|
||||
}
|
||||
}
|
||||
|
||||
const _assertValidCellFormat: (match: RegExpExecArray | null, cell: string) => asserts match is RegExpExecArray = (
|
||||
match,
|
||||
cell
|
||||
) => {
|
||||
if (!match) {
|
||||
throw new InvalidA1NotationError(`Invalid cell format: ${cell}`)
|
||||
}
|
||||
}
|
||||
|
||||
const _columnToIndex = (col: string): number =>
|
||||
col
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.reduce((acc, char) => acc * BASE_26 + char.charCodeAt(0) - ASCII_UPPERCASE_A + COLUMN_INDEX_OFFSET, 0) -
|
||||
COLUMN_INDEX_OFFSET
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { isApiError } from '@botpress/client'
|
||||
import { createAsyncFnWrapperWithErrorRedaction, createErrorHandlingDecorator, posthogHelper } from '@botpress/common'
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import { Common as GoogleApisCommon } from 'googleapis'
|
||||
import { INTEGRATION_NAME, INTEGRATION_VERSION } from 'integration.definition'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const wrapAsyncFnWithTryCatch = createAsyncFnWrapperWithErrorRedaction((error: Error, customMessage: string) => {
|
||||
if (error instanceof sdk.RuntimeError) {
|
||||
return error
|
||||
}
|
||||
|
||||
const googleError = _extractGoogleApiError(error)
|
||||
const redactedMessage = googleError ? `${customMessage}: ${googleError}` : customMessage
|
||||
|
||||
const errorMessage = error.message || String(error)
|
||||
const distinctId = isApiError(error) ? error.id : undefined
|
||||
const statusCode = _isGaxiosError(error) ? error.response?.status : undefined
|
||||
const errorReason = _isGaxiosError(error) ? error.response?.statusText : undefined
|
||||
|
||||
posthogHelper
|
||||
.sendPosthogEvent(
|
||||
{
|
||||
distinctId: distinctId ?? 'no id',
|
||||
event: 'gsheets_api_error',
|
||||
properties: {
|
||||
from: 'google_sheets_client',
|
||||
errorMessage: customMessage,
|
||||
googleError: googleError?.substring(0, 200) || errorMessage.substring(0, 200),
|
||||
statusCode: statusCode?.toString(),
|
||||
errorReason: errorReason?.substring(0, 100),
|
||||
},
|
||||
},
|
||||
{
|
||||
integrationName: INTEGRATION_NAME,
|
||||
integrationVersion: INTEGRATION_VERSION,
|
||||
key: bp.secrets.POSTHOG_KEY,
|
||||
}
|
||||
)
|
||||
.catch(() => {
|
||||
// Silently fail if PostHog is unavailable
|
||||
})
|
||||
|
||||
return new sdk.RuntimeError(redactedMessage)
|
||||
})
|
||||
|
||||
export const handleErrorsDecorator = createErrorHandlingDecorator(wrapAsyncFnWithTryCatch)
|
||||
|
||||
const _extractGoogleApiError = (error: Error) =>
|
||||
_isGaxiosError(error)
|
||||
? error['errors']
|
||||
.map((err: { message: string }) => err.message)
|
||||
.join(', ')
|
||||
.replaceAll(/Invalid requests\[0\].[a-zA-Z]+:/g, '')
|
||||
: null
|
||||
|
||||
type AggregateGAxiosError = GoogleApisCommon.GaxiosError & { errors: Error[] }
|
||||
|
||||
const _isGaxiosError = (error: Error): error is AggregateGAxiosError =>
|
||||
'errors' in error && Array.isArray(error['errors'])
|
||||
@@ -0,0 +1,404 @@
|
||||
import { google } from 'googleapis'
|
||||
import { MajorDimension } from '../../definitions/actions'
|
||||
import { A1NotationParser } from './a1-notation-utils/a1-parser'
|
||||
import { handleErrorsDecorator as handleErrors } from './error-handling'
|
||||
import { ResponseMapping } from './mapping/response-mapping'
|
||||
import { getAuthenticatedOAuth2Client, exchangeAuthCodeAndSaveRefreshToken } from './oauth-client'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type GoogleSheetsClient = ReturnType<typeof google.sheets>
|
||||
type GoogleOAuth2Client = InstanceType<(typeof google.auth)['OAuth2']>
|
||||
|
||||
type RangeOnly = { rangeA1: string }
|
||||
type Range = { majorDimension?: MajorDimension } & RangeOnly
|
||||
type ValueRange = { values: any[][] } & Range
|
||||
|
||||
export class GoogleClient {
|
||||
private readonly _sheetsClient: GoogleSheetsClient
|
||||
private readonly _spreadsheetId: string
|
||||
|
||||
private constructor({ spreadsheetId, oauthClient }: { spreadsheetId: string; oauthClient: GoogleOAuth2Client }) {
|
||||
this._spreadsheetId = spreadsheetId
|
||||
|
||||
this._sheetsClient = google.sheets({ version: 'v4', auth: oauthClient })
|
||||
}
|
||||
|
||||
public static async create({ ctx, client }: { ctx: bp.Context; client: bp.Client }) {
|
||||
const oauth2Client = await getAuthenticatedOAuth2Client({ ctx, client })
|
||||
|
||||
const getSpreadsheetIdFromState = async (): Promise<string> => {
|
||||
let spreadsheetId: string
|
||||
if (ctx.configurationType === 'serviceAccountKey') {
|
||||
spreadsheetId = ctx.configuration.spreadsheetId
|
||||
} else {
|
||||
const { state } = await client.getState({
|
||||
id: ctx.integrationId,
|
||||
type: 'integration',
|
||||
name: 'spreadsheetConfig',
|
||||
})
|
||||
spreadsheetId = state.payload.spreadsheetId
|
||||
}
|
||||
return spreadsheetId
|
||||
}
|
||||
|
||||
const spreadsheetId = await getSpreadsheetIdFromState()
|
||||
|
||||
return new GoogleClient({
|
||||
oauthClient: oauth2Client,
|
||||
spreadsheetId,
|
||||
})
|
||||
}
|
||||
|
||||
public static async authenticateWithAuthorizationCode({
|
||||
ctx,
|
||||
client,
|
||||
authorizationCode,
|
||||
redirectUri,
|
||||
}: {
|
||||
ctx: bp.Context
|
||||
client: bp.Client
|
||||
authorizationCode: string
|
||||
redirectUri: string
|
||||
}) {
|
||||
await exchangeAuthCodeAndSaveRefreshToken({ ctx, client, authorizationCode, redirectUri })
|
||||
}
|
||||
|
||||
@handleErrors('Failed to get values from spreadsheet range')
|
||||
public async getValuesFromSpreadsheetRange({ rangeA1, majorDimension }: Range) {
|
||||
const response = await this._sheetsClient.spreadsheets.values.get({
|
||||
spreadsheetId: this._spreadsheetId,
|
||||
range: rangeA1,
|
||||
majorDimension: majorDimension ?? 'ROWS',
|
||||
})
|
||||
return ResponseMapping.mapValueRange(response.data)
|
||||
}
|
||||
|
||||
@handleErrors('Failed to update values in spreadsheet range')
|
||||
public async updateValuesInSpreadsheetRange({ rangeA1, values, majorDimension }: ValueRange) {
|
||||
const response = await this._sheetsClient.spreadsheets.values.update({
|
||||
spreadsheetId: this._spreadsheetId,
|
||||
range: rangeA1,
|
||||
valueInputOption: 'USER_ENTERED',
|
||||
requestBody: { range: rangeA1, values, majorDimension },
|
||||
})
|
||||
return ResponseMapping.mapUpdateValues(response.data)
|
||||
}
|
||||
|
||||
@handleErrors('Failed to append values to spreadsheet range')
|
||||
public async appendValuesToSpreadsheetRange({ rangeA1, values, majorDimension }: ValueRange) {
|
||||
const response = await this._sheetsClient.spreadsheets.values.append({
|
||||
spreadsheetId: this._spreadsheetId,
|
||||
range: rangeA1,
|
||||
valueInputOption: 'USER_ENTERED',
|
||||
requestBody: { range: rangeA1, values, majorDimension },
|
||||
})
|
||||
return ResponseMapping.mapAppendValues(response.data)
|
||||
}
|
||||
|
||||
@handleErrors('Failed to clear values from spreadsheet range')
|
||||
public async clearValuesFromSpreadsheetRange({ rangeA1 }: RangeOnly) {
|
||||
const response = await this._sheetsClient.spreadsheets.values.clear({
|
||||
spreadsheetId: this._spreadsheetId,
|
||||
range: rangeA1,
|
||||
requestBody: { range: rangeA1 },
|
||||
})
|
||||
return ResponseMapping.mapClearValues(response.data)
|
||||
}
|
||||
|
||||
@handleErrors('Failed to create new sheet in spreadsheet')
|
||||
public async createNewSheetInSpreadsheet({ sheetTitle }: { sheetTitle: string }) {
|
||||
const response = await this._sheetsClient.spreadsheets.batchUpdate({
|
||||
spreadsheetId: this._spreadsheetId,
|
||||
requestBody: {
|
||||
requests: [
|
||||
{
|
||||
addSheet: {
|
||||
properties: {
|
||||
title: sheetTitle,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
return ResponseMapping.mapAddSheet(response.data)
|
||||
}
|
||||
|
||||
@handleErrors('Failed to get sheets from spreadsheet')
|
||||
public async getAllSheetsInSpreadsheet() {
|
||||
const meta = await this.getSpreadsheetMetadata({
|
||||
fields: 'sheets.properties,sheets.protectedRanges.unprotectedRanges',
|
||||
})
|
||||
return meta.sheets?.map(ResponseMapping.mapSheet) ?? []
|
||||
}
|
||||
|
||||
@handleErrors('Failed to delete sheet from spreadsheet')
|
||||
public async deleteSheetFromSpreadsheet({ sheetId }: { sheetId: number }) {
|
||||
await this._sheetsClient.spreadsheets.batchUpdate({
|
||||
spreadsheetId: this._spreadsheetId,
|
||||
requestBody: {
|
||||
requests: [
|
||||
{
|
||||
deleteSheet: {
|
||||
sheetId,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@handleErrors('Failed to rename sheet in spreadsheet')
|
||||
public async renameSheetInSpreadsheet({ sheetId, newTitle }: { sheetId: number; newTitle: string }) {
|
||||
await this._sheetsClient.spreadsheets.batchUpdate({
|
||||
spreadsheetId: this._spreadsheetId,
|
||||
requestBody: {
|
||||
requests: [
|
||||
{
|
||||
updateSheetProperties: {
|
||||
properties: {
|
||||
sheetId,
|
||||
title: newTitle,
|
||||
},
|
||||
fields: 'title',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@handleErrors('Failed to set sheet visibility')
|
||||
public async setSheetVisibility({ sheetId, isHidden }: { sheetId: number; isHidden: boolean }) {
|
||||
await this._sheetsClient.spreadsheets.batchUpdate({
|
||||
spreadsheetId: this._spreadsheetId,
|
||||
requestBody: {
|
||||
requests: [
|
||||
{
|
||||
updateSheetProperties: {
|
||||
properties: {
|
||||
sheetId,
|
||||
hidden: isHidden,
|
||||
},
|
||||
fields: 'hidden',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@handleErrors('Failed to move sheet to new index')
|
||||
public async moveSheetToIndex({ sheetId, newIndex }: { sheetId: number; newIndex: number }) {
|
||||
await this._sheetsClient.spreadsheets.batchUpdate({
|
||||
spreadsheetId: this._spreadsheetId,
|
||||
requestBody: {
|
||||
requests: [
|
||||
{
|
||||
updateSheetProperties: {
|
||||
properties: {
|
||||
sheetId,
|
||||
index: newIndex,
|
||||
},
|
||||
fields: 'index',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@handleErrors('Failed to get spreadsheet metadata')
|
||||
public async getSpreadsheetMetadata({ fields }: { fields?: string } = {}) {
|
||||
const response = await this._sheetsClient.spreadsheets.get({
|
||||
spreadsheetId: this._spreadsheetId,
|
||||
fields,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
@handleErrors('Failed to get named ranges of spreadsheet')
|
||||
public async getNamedRanges() {
|
||||
const response = await this._sheetsClient.spreadsheets.get({
|
||||
spreadsheetId: this._spreadsheetId,
|
||||
fields: 'namedRanges',
|
||||
})
|
||||
|
||||
return response.data.namedRanges?.map(ResponseMapping.mapNamedRange) ?? []
|
||||
}
|
||||
|
||||
@handleErrors('Failed to get protected ranges of spreadsheet')
|
||||
public async getProtectedRanges() {
|
||||
const response = await this._sheetsClient.spreadsheets.get({
|
||||
spreadsheetId: this._spreadsheetId,
|
||||
fields: 'sheets.protectedRanges',
|
||||
})
|
||||
|
||||
return (
|
||||
response.data.sheets?.flatMap((sheet) => sheet.protectedRanges?.map(ResponseMapping.mapProtectedRange) ?? []) ??
|
||||
[]
|
||||
)
|
||||
}
|
||||
|
||||
@handleErrors('Failed to protect named range')
|
||||
public async protectNamedRange({
|
||||
namedRangeId,
|
||||
requestingUserCanEdit,
|
||||
warningOnly,
|
||||
}: {
|
||||
namedRangeId: string
|
||||
requestingUserCanEdit?: boolean
|
||||
warningOnly?: boolean
|
||||
}) {
|
||||
const reponse = await this._sheetsClient.spreadsheets.batchUpdate({
|
||||
spreadsheetId: this._spreadsheetId,
|
||||
requestBody: {
|
||||
requests: [
|
||||
{
|
||||
addProtectedRange: {
|
||||
protectedRange: { namedRangeId, requestingUserCanEdit, warningOnly },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const protectedRangeId = reponse.data.replies?.[0]?.addProtectedRange?.protectedRange?.protectedRangeId ?? 0
|
||||
return { protectedRangeId }
|
||||
}
|
||||
|
||||
@handleErrors('Failed to unprotect named range')
|
||||
public async unprotectRange({ protectedRangeId }: { protectedRangeId: number }) {
|
||||
await this._sheetsClient.spreadsheets.batchUpdate({
|
||||
spreadsheetId: this._spreadsheetId,
|
||||
requestBody: {
|
||||
requests: [
|
||||
{
|
||||
deleteProtectedRange: {
|
||||
protectedRangeId,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@handleErrors('Failed to create named range in sheet')
|
||||
public async createNamedRangeInSheet({
|
||||
sheetId,
|
||||
rangeName,
|
||||
a1Notation,
|
||||
}: {
|
||||
sheetId: number
|
||||
rangeName: string
|
||||
a1Notation: string
|
||||
}) {
|
||||
const response = await this._sheetsClient.spreadsheets.batchUpdate({
|
||||
spreadsheetId: this._spreadsheetId,
|
||||
requestBody: {
|
||||
requests: [
|
||||
{
|
||||
addNamedRange: {
|
||||
namedRange: {
|
||||
name: rangeName,
|
||||
range: {
|
||||
sheetId,
|
||||
...A1NotationParser.parse(a1Notation),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const namedRangeId = response.data.replies?.[0]?.addNamedRange?.namedRange?.namedRangeId ?? ''
|
||||
return { namedRangeId }
|
||||
}
|
||||
|
||||
public async getSpreadsheetSummary(): Promise<string> {
|
||||
const { properties, sheets } = await this.getSpreadsheetMetadata()
|
||||
|
||||
const title = properties?.title ?? 'No Title'
|
||||
const sheetsTitles = sheets?.map((sheet) => sheet.properties?.title).filter(Boolean) ?? []
|
||||
|
||||
return `spreadsheet "${title}"` + (sheetsTitles.length ? ` with sheets "${sheetsTitles.join('", "')}"` : '')
|
||||
}
|
||||
|
||||
@handleErrors('Failed to get sheet ID by name')
|
||||
public async getSheetIdByName(sheetName?: string): Promise<{ sheetId: number; sheetTitle: string }> {
|
||||
const meta = await this.getSpreadsheetMetadata({ fields: 'sheets.properties' })
|
||||
const sheets = meta.sheets ?? []
|
||||
|
||||
if (!sheetName) {
|
||||
const firstVisibleSheet = sheets.find((s) => !s.properties?.hidden)
|
||||
if (!firstVisibleSheet?.properties) {
|
||||
throw new Error('No visible sheets found in spreadsheet')
|
||||
}
|
||||
return {
|
||||
sheetId: firstVisibleSheet.properties.sheetId ?? 0,
|
||||
sheetTitle: firstVisibleSheet.properties.title ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
const sheet = sheets.find((s) => s.properties?.title === sheetName)
|
||||
if (!sheet?.properties) {
|
||||
throw new Error(`Sheet "${sheetName}" not found`)
|
||||
}
|
||||
return {
|
||||
sheetId: sheet.properties.sheetId ?? 0,
|
||||
sheetTitle: sheet.properties.title ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
@handleErrors('Failed to insert rows')
|
||||
public async insertRows({
|
||||
sheetId,
|
||||
startIndex,
|
||||
numberOfRows = 1,
|
||||
}: {
|
||||
sheetId: number
|
||||
startIndex: number
|
||||
numberOfRows?: number
|
||||
}) {
|
||||
await this._sheetsClient.spreadsheets.batchUpdate({
|
||||
spreadsheetId: this._spreadsheetId,
|
||||
requestBody: {
|
||||
requests: [
|
||||
{
|
||||
insertDimension: {
|
||||
range: {
|
||||
sheetId,
|
||||
dimension: 'ROWS',
|
||||
startIndex,
|
||||
endIndex: startIndex + numberOfRows,
|
||||
},
|
||||
inheritFromBefore: startIndex > 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@handleErrors('Failed to delete rows')
|
||||
public async deleteRowsFromSheet({ sheetId, rowIndexes }: { sheetId: number; rowIndexes: number[] }) {
|
||||
const sortedIndexes = [...rowIndexes].sort((a, b) => b - a)
|
||||
|
||||
const requests = sortedIndexes.map((rowIndex) => ({
|
||||
deleteDimension: {
|
||||
range: {
|
||||
sheetId,
|
||||
dimension: 'ROWS' as const,
|
||||
startIndex: rowIndex - 1,
|
||||
endIndex: rowIndex,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
await this._sheetsClient.spreadsheets.batchUpdate({
|
||||
spreadsheetId: this._spreadsheetId,
|
||||
requestBody: { requests },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './google-client'
|
||||
export * from './error-handling'
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { sheets_v4 } from 'googleapis'
|
||||
import { A1Converter } from '../a1-notation-utils/a1-converter'
|
||||
|
||||
export namespace ResponseMapping {
|
||||
export const mapValueRange = (response: sheets_v4.Schema$ValueRange) =>
|
||||
({
|
||||
range: response.range ?? '',
|
||||
majorDimension: response.majorDimension === 'COLUMNS' ? 'COLUMNS' : 'ROWS',
|
||||
values: _stringifyValues(response.values ?? []),
|
||||
}) as const
|
||||
|
||||
export const mapUpdateValues = (response: sheets_v4.Schema$UpdateValuesResponse) =>
|
||||
({
|
||||
spreadsheetId: response.spreadsheetId ?? '',
|
||||
updatedRange: response.updatedRange ?? '',
|
||||
updatedRows: response.updatedRows ?? 0,
|
||||
updatedColumns: response.updatedColumns ?? 0,
|
||||
updatedCells: response.updatedCells ?? 0,
|
||||
}) as const
|
||||
|
||||
export const mapAppendValues = (response: sheets_v4.Schema$AppendValuesResponse) =>
|
||||
({
|
||||
spreadsheetId: response.spreadsheetId ?? '',
|
||||
tableRange: response.tableRange ?? '',
|
||||
updates: mapUpdateValues(response.updates ?? {}),
|
||||
}) as const
|
||||
|
||||
export const mapClearValues = (response: sheets_v4.Schema$ClearValuesResponse) =>
|
||||
({
|
||||
spreadsheetId: response.spreadsheetId ?? '',
|
||||
clearedRange: response.clearedRange ?? '',
|
||||
}) as const
|
||||
|
||||
export const mapAddSheet = (response: sheets_v4.Schema$BatchUpdateSpreadsheetResponse) =>
|
||||
({
|
||||
spreadsheetId: response.spreadsheetId ?? '',
|
||||
newSheet: mapSheet(response.replies?.[0]?.addSheet ?? {}),
|
||||
}) as const
|
||||
|
||||
export const mapSheet = (sheet: sheets_v4.Schema$Sheet) =>
|
||||
({
|
||||
sheetId: sheet.properties?.sheetId ?? 0,
|
||||
title: sheet.properties?.title ?? '',
|
||||
index: sheet.properties?.index ?? 0,
|
||||
isHidden: sheet.properties?.hidden ?? false,
|
||||
hasProtectedRanges: (sheet.protectedRanges?.length ?? 0) > 0,
|
||||
isFullyProtected: sheet.protectedRanges?.every((range) => range.unprotectedRanges === undefined) ?? false,
|
||||
}) as const
|
||||
|
||||
export const mapNamedRange = (namedRange: sheets_v4.Schema$NamedRange) =>
|
||||
({
|
||||
namedRangeId: namedRange.namedRangeId ?? '',
|
||||
name: namedRange.name ?? '',
|
||||
range: A1Converter.gridRangeToA1(namedRange.range ?? {}),
|
||||
sheetId: namedRange.range?.sheetId ?? 0,
|
||||
}) as const
|
||||
|
||||
export const mapProtectedRange = (protectedRange: sheets_v4.Schema$ProtectedRange) =>
|
||||
({
|
||||
protectedRangeId: protectedRange.protectedRangeId ?? 0,
|
||||
namedRangeId: protectedRange.namedRangeId ?? '',
|
||||
range: A1Converter.gridRangeToA1(protectedRange.range ?? {}),
|
||||
sheetId: protectedRange.range?.sheetId ?? 0,
|
||||
description: protectedRange.description ?? '',
|
||||
warningOnly: protectedRange.warningOnly ?? false,
|
||||
requestingUserCanEdit: protectedRange.requestingUserCanEdit ?? false,
|
||||
}) as const
|
||||
}
|
||||
|
||||
const _stringifyValues = (values: any[][]) => values.map((majorDimension) => majorDimension.map(String))
|
||||
@@ -0,0 +1,70 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import { google } from 'googleapis'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type GoogleOAuth2Client = InstanceType<(typeof google.auth)['OAuth2']>
|
||||
|
||||
const OAUTH_SCOPES = ['https://www.googleapis.com/auth/spreadsheets']
|
||||
// Note: This endpoint is used to construct the OAuth2 client but is overridden
|
||||
// when exchanging tokens. The actual redirect URI is passed explicitly.
|
||||
const GLOBAL_OAUTH_ENDPOINT = `${process.env.BP_WEBHOOK_URL}/oauth`
|
||||
|
||||
export const exchangeAuthCodeAndSaveRefreshToken = async ({
|
||||
ctx,
|
||||
client,
|
||||
authorizationCode,
|
||||
redirectUri,
|
||||
}: {
|
||||
ctx: bp.Context
|
||||
client: bp.Client
|
||||
authorizationCode: string
|
||||
redirectUri: string
|
||||
}) => {
|
||||
const oauth2Client = _getPlainOAuth2Client()
|
||||
|
||||
const { tokens } = await oauth2Client.getToken({
|
||||
code: authorizationCode,
|
||||
redirect_uri: redirectUri,
|
||||
})
|
||||
|
||||
if (!tokens.refresh_token) {
|
||||
throw new sdk.RuntimeError('Unable to obtain refresh token. Please try the OAuth flow again.')
|
||||
}
|
||||
|
||||
await client.setState({
|
||||
id: ctx.integrationId,
|
||||
type: 'integration',
|
||||
name: 'oAuthConfig',
|
||||
payload: { refreshToken: tokens.refresh_token },
|
||||
})
|
||||
}
|
||||
|
||||
export const getAuthenticatedOAuth2Client = async ({
|
||||
ctx,
|
||||
client,
|
||||
}: {
|
||||
ctx: bp.Context
|
||||
client: bp.Client
|
||||
}): Promise<GoogleOAuth2Client> => {
|
||||
if (ctx.configurationType === 'serviceAccountKey') {
|
||||
return new google.auth.JWT({
|
||||
email: ctx.configuration.clientEmail,
|
||||
key: ctx.configuration.privateKey.split(String.raw`\n`).join('\n'),
|
||||
scopes: OAUTH_SCOPES,
|
||||
})
|
||||
}
|
||||
|
||||
const oauth2Client = _getPlainOAuth2Client()
|
||||
|
||||
const { state } = await client.getState({
|
||||
id: ctx.integrationId,
|
||||
type: 'integration',
|
||||
name: 'oAuthConfig',
|
||||
})
|
||||
|
||||
oauth2Client.setCredentials({ refresh_token: state.payload.refreshToken })
|
||||
return oauth2Client
|
||||
}
|
||||
|
||||
const _getPlainOAuth2Client = (): GoogleOAuth2Client =>
|
||||
new google.auth.OAuth2(bp.secrets.CLIENT_ID, bp.secrets.CLIENT_SECRET, GLOBAL_OAUTH_ENDPOINT)
|
||||
@@ -0,0 +1,23 @@
|
||||
import { posthogHelper } from '@botpress/common'
|
||||
import { INTEGRATION_NAME, INTEGRATION_VERSION } from 'integration.definition'
|
||||
import actions from './actions'
|
||||
import { channels } from './channels'
|
||||
import { register, unregister } from './setup'
|
||||
import { handler } from './webhook-events'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const posthogConfig: posthogHelper.PostHogConfig = {
|
||||
integrationName: INTEGRATION_NAME,
|
||||
key: bp.secrets.POSTHOG_KEY,
|
||||
integrationVersion: INTEGRATION_VERSION,
|
||||
}
|
||||
|
||||
const integrationConfig: bp.IntegrationProps = {
|
||||
register,
|
||||
unregister,
|
||||
actions,
|
||||
channels,
|
||||
handler,
|
||||
}
|
||||
|
||||
export default posthogHelper.wrapIntegration(posthogConfig, integrationConfig)
|
||||
@@ -0,0 +1,12 @@
|
||||
import { GoogleClient } from './google-api/google-client'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const register: bp.IntegrationProps['register'] = async ({ logger, ctx, client }) => {
|
||||
logger.forBot().info('Registering Google Sheets integration')
|
||||
|
||||
const gsheetsClient = await GoogleClient.create({ ctx, client })
|
||||
const summary = await gsheetsClient.getSpreadsheetSummary()
|
||||
logger.forBot().info(`Successfully connected to Google Sheets: ${summary}`)
|
||||
}
|
||||
|
||||
export const unregister: bp.IntegrationProps['unregister'] = async () => {}
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as oauthWizard from '@botpress/common/src/oauth-wizard'
|
||||
import { handleOAuthWizard } from './handlers/oauth-wizard'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const handler: bp.IntegrationProps['handler'] = async (props) => {
|
||||
const { req, logger } = props
|
||||
|
||||
if (oauthWizard.isOAuthWizardUrl(req.path)) {
|
||||
logger.forBot().info('Handling Google Sheets OAuth wizard')
|
||||
return await handleOAuthWizard(props)
|
||||
}
|
||||
|
||||
logger.forBot().debug('Received unsupported webhook event', { path: req.path, query: req.query })
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { GoogleClient } from 'src/google-api'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const oauthCallbackHandler = async ({ client, ctx, req, logger }: bp.HandlerProps) => {
|
||||
const searchParams = new URLSearchParams(req.query)
|
||||
const authorizationCode = searchParams.get('code')
|
||||
|
||||
if (!authorizationCode) {
|
||||
return
|
||||
}
|
||||
|
||||
// Note: This handler is deprecated in favor of the OAuth wizard
|
||||
// but kept for backward compatibility
|
||||
const redirectUri = `${process.env.BP_WEBHOOK_URL}/oauth`
|
||||
|
||||
await GoogleClient.authenticateWithAuthorizationCode({
|
||||
client,
|
||||
ctx,
|
||||
authorizationCode,
|
||||
redirectUri,
|
||||
})
|
||||
|
||||
await client.configureIntegration({ identifier: ctx.webhookId })
|
||||
|
||||
logger.forBot().info('Successfully authenticated with Google Sheets')
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import * as oauthWizard from '@botpress/common/src/oauth-wizard'
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import { GoogleClient } from 'src/google-api'
|
||||
import { getAuthenticatedOAuth2Client } from 'src/google-api/oauth-client'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const handleOAuthWizard = async (props: bp.HandlerProps): Promise<sdk.Response> => {
|
||||
const { ctx } = props
|
||||
|
||||
const wizard = new oauthWizard.OAuthWizardBuilder(props)
|
||||
|
||||
.addStep({
|
||||
id: 'start',
|
||||
handler({ responses }) {
|
||||
return responses.displayButtons({
|
||||
pageTitle: 'Google Sheets Integration',
|
||||
htmlOrMarkdownPageContents: `
|
||||
This wizard will set up your Google Sheets integration. This will allow
|
||||
the integration to access your Google Sheets files.
|
||||
|
||||
Do you wish to continue?
|
||||
`,
|
||||
buttons: [
|
||||
{
|
||||
action: 'external',
|
||||
label: 'Yes, continue',
|
||||
navigateToUrl: _getOAuthAuthorizationUri(ctx),
|
||||
buttonType: 'primary',
|
||||
},
|
||||
{ action: 'close', label: 'No, cancel', buttonType: 'secondary' },
|
||||
],
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
.addStep({
|
||||
id: 'oauth-callback',
|
||||
async handler({ query, responses, client, ctx, logger }) {
|
||||
const authorizationCode = query.get('code')
|
||||
const error = query.get('error')
|
||||
const state = query.get('state')
|
||||
|
||||
if (error) {
|
||||
logger.forBot().error('OAuth error:', error)
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: `OAuth error: ${error}`,
|
||||
})
|
||||
}
|
||||
|
||||
// Validate state parameter to prevent CSRF attacks
|
||||
if (state !== ctx.webhookId) {
|
||||
logger
|
||||
.forBot()
|
||||
.error('OAuth state mismatch — possible CSRF attack', { expected: ctx.webhookId, received: state })
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: 'Invalid OAuth state parameter.',
|
||||
})
|
||||
}
|
||||
|
||||
if (!authorizationCode) {
|
||||
logger.forBot().error('Error extracting code from url in OAuth handler')
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: 'Error extracting code from url in OAuth handler',
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await GoogleClient.authenticateWithAuthorizationCode({
|
||||
client,
|
||||
ctx,
|
||||
authorizationCode,
|
||||
redirectUri: _getOAuthRedirectUri().href,
|
||||
})
|
||||
|
||||
// Done in order to correctly display the authorization status in the UI
|
||||
await client.configureIntegration({
|
||||
identifier: ctx.webhookId,
|
||||
})
|
||||
|
||||
return responses.redirectToStep('file-picker')
|
||||
} catch (err) {
|
||||
logger.forBot().error('Failed to authenticate with Google:', err)
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: `Authentication failed: ${err instanceof Error ? err.message : 'Unknown error'}`,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
.addStep({
|
||||
id: 'file-picker',
|
||||
async handler({ responses, client, ctx, logger }) {
|
||||
let accessToken: string
|
||||
try {
|
||||
accessToken = await _getAccessToken({ client, ctx })
|
||||
} catch (err) {
|
||||
logger.forBot().error('Failed to get access token for file picker:', err)
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: `Failed to get access token: ${err instanceof Error ? err.message : 'Unknown error'}`,
|
||||
})
|
||||
}
|
||||
|
||||
return responses.displayButtons({
|
||||
pageTitle: 'Google Sheets Integration',
|
||||
htmlOrMarkdownPageContents: `
|
||||
You will now be asked to select the spreadsheet you wish to use
|
||||
with this integration. This is necessary for the integration to work
|
||||
properly.
|
||||
|
||||
<script>
|
||||
let pickerApiLoaded = false;
|
||||
|
||||
function onPickerApiLoad() {
|
||||
pickerApiLoaded = true;
|
||||
}
|
||||
|
||||
function createPicker() {
|
||||
if (!pickerApiLoaded) {
|
||||
alert('The file picker is still loading. Please try again in a moment.');
|
||||
return;
|
||||
}
|
||||
|
||||
new google.picker.PickerBuilder()
|
||||
.addView(new google.picker.DocsView(google.picker.ViewId.SPREADSHEETS)
|
||||
.setIncludeFolders(true)
|
||||
.setSelectFolderEnabled(false)
|
||||
.setMode(google.picker.DocsViewMode.LIST))
|
||||
.enableFeature(google.picker.Feature.NAV_HIDDEN)
|
||||
.setTitle('Select the spreadsheet you wish to use with Botpress')
|
||||
.setOAuthToken(${JSON.stringify(accessToken)})
|
||||
.setDeveloperKey(${JSON.stringify(bp.secrets.FILE_PICKER_API_KEY)})
|
||||
.setCallback((data) => {
|
||||
if (data[google.picker.Response.ACTION] == google.picker.Action.PICKED) {
|
||||
const docs = data[google.picker.Response.DOCUMENTS];
|
||||
if (docs && docs.length > 0) {
|
||||
const spreadsheetId = docs[0].id;
|
||||
const nextUrl = new URL(${JSON.stringify(oauthWizard.getWizardStepUrl('end', ctx).href)});
|
||||
nextUrl.searchParams.set('spreadsheetId', spreadsheetId);
|
||||
document.location.href = nextUrl.href;
|
||||
} else {
|
||||
document.location.href = ${JSON.stringify(oauthWizard.getWizardStepUrl('end', ctx).href)};
|
||||
}
|
||||
} else if (data[google.picker.Response.ACTION] == google.picker.Action.CANCEL) {
|
||||
// User closed the picker without selecting files.
|
||||
// They can still click "Skip file selection" to continue.
|
||||
}
|
||||
})
|
||||
.setSize(640,790)
|
||||
// App ID must be the Google Cloud project number (numeric prefix of CLIENT_ID)
|
||||
// Format: <project-number>-<rest>.apps.googleusercontent.com
|
||||
.setAppId(${JSON.stringify(bp.secrets.CLIENT_ID.split('-')[0])})
|
||||
.build().setVisible(true);
|
||||
}
|
||||
</script>
|
||||
<script async defer src="https://apis.google.com/js/api.js" onload="gapi.load('picker', onPickerApiLoad)"></script>
|
||||
`,
|
||||
buttons: [
|
||||
{
|
||||
action: 'javascript',
|
||||
label: 'Select spreadsheet',
|
||||
callFunction: 'createPicker',
|
||||
buttonType: 'primary',
|
||||
},
|
||||
],
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
.addStep({
|
||||
id: 'end',
|
||||
async handler({ responses, query, client, ctx }) {
|
||||
const spreadsheetId = query.get('spreadsheetId')
|
||||
|
||||
if (spreadsheetId) {
|
||||
await client.setState({
|
||||
id: ctx.integrationId,
|
||||
type: 'integration',
|
||||
name: 'spreadsheetConfig',
|
||||
payload: { spreadsheetId },
|
||||
})
|
||||
}
|
||||
|
||||
return responses.endWizard({
|
||||
success: true,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
.build()
|
||||
|
||||
return await wizard.handleRequest()
|
||||
}
|
||||
|
||||
const _getOAuthAuthorizationUri = (ctx: { webhookId: string }) =>
|
||||
'https://accounts.google.com/o/oauth2/v2/auth?' +
|
||||
`scope=${encodeURIComponent('https://www.googleapis.com/auth/drive.file')}` +
|
||||
'&access_type=offline' +
|
||||
'&include_granted_scopes=true' +
|
||||
'&response_type=code' +
|
||||
'&prompt=consent' +
|
||||
`&state=${encodeURIComponent(ctx.webhookId)}` +
|
||||
`&redirect_uri=${encodeURIComponent(_getOAuthRedirectUri().href)}` +
|
||||
`&client_id=${encodeURIComponent(bp.secrets.CLIENT_ID)}`
|
||||
|
||||
const _getOAuthRedirectUri = () => oauthWizard.getWizardStepUrl('oauth-callback')
|
||||
|
||||
const _getAccessToken = async ({ client, ctx }: { client: bp.Client; ctx: bp.Context }): Promise<string> => {
|
||||
const oauth2Client = await getAuthenticatedOAuth2Client({ client, ctx })
|
||||
const { token } = await oauth2Client.getAccessToken()
|
||||
|
||||
if (!token) {
|
||||
throw new sdk.RuntimeError('Failed to get access token')
|
||||
}
|
||||
|
||||
return token
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './handler-dispatcher'
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"types": ["preact"],
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import config from '../../vitest.config'
|
||||
export default config
|
||||
Reference in New Issue
Block a user