chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
+693
View File
@@ -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']
+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+580
View File
@@ -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).
+55
View File
@@ -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',
},
})
+4
View File
@@ -0,0 +1,4 @@
webhookId = to_string!(.webhookId)
webhookUrl = to_string!(.webhookUrl)
"{{ webhookUrl }}/oauth/wizard/start?state={{ webhookId }}"
+23
View File
@@ -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,
}
}
)
+50
View File
@@ -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)
+23
View File
@@ -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)
+12
View File
@@ -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'
+13
View File
@@ -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"]
}
+2
View File
@@ -0,0 +1,2 @@
import config from '../../vitest.config'
export default config