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
+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,
},
},
},
]
+26
View File
@@ -0,0 +1,26 @@
# Botpress Airtable Integration
This integration allows you to connect your Botpress chatbot with Airtable, a popular cloud-based database and collaboration platform. With this integration, you can easily manage your Airtable bases, tables, and records directly from your chatbot.
## Setup
To set up the integration, you will need to provide your Airtable `accessToken`, `baseId`, and `endpointUrl` (Optional). Once the integration is set up, you can use the built-in actions to manage your Airtable data.
For more detailed instructions on how to set up and use the Botpress Airtable integration, please refer to our documentation.
### Prerequisites
Before enabling the Botpress Airtable Integration, please ensure that you have the following:
- A Botpress cloud account.
- `accessToken`, `baseId`, and `endpointUrl` (Optional) generated from Airtable.
### Enable Integration
To enable the Airtable integration in Botpress, follow these steps:
1. Access your Botpress admin panel.
2. Navigate to the “Integrations” section.
3. Locate the Airtable integration and click on “Enable” or “Configure.”
4. Provide the required `accessToken`, `baseId`, and `endpointUrl` (Optional).
5. Save the configuration.
+6
View File
@@ -0,0 +1,6 @@
<svg width="141" height="119" viewBox="0 0 141 119" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M62.8487 2.13658L10.5954 23.8292C7.68963 25.0354 7.71975 29.1761 10.6441 30.3394L63.1154 51.2154C67.7258 53.0499 72.8601 53.0499 77.4704 51.2154L129.943 30.3389C132.866 29.1766 132.897 25.0359 129.99 23.8292L77.7377 2.13603C72.9707 0.15713 67.6157 0.15713 62.8487 2.13603" fill="#FFBF00"/>
<path d="M74.9485 62.6141V114.765C74.9485 117.246 77.4407 118.945 79.739 118.031L138.209 95.261C138.861 95.0017 139.42 94.5517 139.814 93.9696C140.208 93.3875 140.419 92.7 140.419 91.9963V39.8445C140.419 37.3639 137.926 35.6659 135.628 36.5797L77.1581 59.3488C76.5061 59.6084 75.9468 60.0584 75.5527 60.6406C75.1586 61.2228 74.948 61.9104 74.948 62.6141" fill="#26B5F8"/>
<path d="M61.2952 65.3054L43.943 73.7109L42.1809 74.5655L5.55065 92.174C3.22881 93.2977 0.265015 91.6003 0.265015 89.0125V40.0634C0.265015 39.127 0.743734 38.3186 1.38568 37.7098C1.64868 37.4472 1.94698 37.2229 2.27191 37.0432C3.14719 36.5162 4.39603 36.3755 5.45754 36.797L61.0033 58.877C63.8269 60.0008 64.0487 63.97 61.2952 65.3048" fill="#ED3049"/>
<path d="M61.2953 65.3063L43.9431 73.7119L1.38525 37.7112C1.64825 37.4487 1.94655 37.2243 2.27149 37.0447C3.14677 36.5177 4.3956 36.377 5.45711 36.7985L61.0029 58.8785C63.8264 60.0023 64.0483 63.9715 61.2948 65.3063" fill="black" fill-opacity="0.25"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,142 @@
import { z, IntegrationDefinition } from '@botpress/sdk'
import {
createRecordInputSchema,
createRecordOutputSchema,
createTableInputSchema,
createTableOutputSchema,
getTableRecordsInputSchema,
getTableRecordsOutputSchema,
updateRecordInputSchema,
updateRecordOutputSchema,
updateTableInputSchema,
updateTableOutputSchema,
listRecordsInputSchema,
listRecordsOutputSchema,
} from './src/misc/custom-schemas'
export default new IntegrationDefinition({
name: 'airtable',
title: 'Airtable',
description:
'Access and manage Airtable data to allow your chatbot to retrieve details, update records, and organize information.',
version: '3.0.2',
readme: 'hub.md',
icon: 'icon.svg',
configuration: {
identifier: {
linkTemplateScript: 'linkTemplate.vrl',
},
schema: z.object({}),
},
channels: {},
user: {
tags: {},
},
actions: {
getTableRecords: {
title: 'Get Table Records',
description: 'Get Records of the Table',
input: {
schema: getTableRecordsInputSchema,
},
output: {
schema: getTableRecordsOutputSchema,
},
},
createTable: {
title: 'Create Table',
description: 'Creates a new table and returns the schema for the newly created table.',
input: {
schema: createTableInputSchema,
},
output: {
schema: createTableOutputSchema,
},
},
updateTable: {
title: 'Update Table',
description: 'Updates the name, description, and/or date dependency settings of a table.',
input: {
schema: updateTableInputSchema,
},
output: {
schema: updateTableOutputSchema,
},
},
createRecord: {
title: 'Create Record',
description: 'Create a record',
input: {
schema: createRecordInputSchema,
},
output: {
schema: createRecordOutputSchema,
},
},
updateRecord: {
title: 'Update Record',
description: 'Updates a single record.',
input: {
schema: updateRecordInputSchema,
},
output: {
schema: updateRecordOutputSchema,
},
},
listRecords: {
title: 'List Records',
description: 'List records in a table.',
input: {
schema: listRecordsInputSchema,
},
output: {
schema: listRecordsOutputSchema,
},
},
},
events: {},
states: {
oAuthCredentials: {
type: 'integration',
schema: z.object({
accessToken: z.string().secret().describe('The OAuth access token'),
refreshToken: z.string().secret().describe('The rotating OAuth refresh token'),
expiresAt: z.string().datetime().describe('The timestamp of when the access token expires'),
refreshExpiresAt: z.string().datetime().describe('The timestamp of when the refresh token expires'),
scopes: z.array(z.string()).describe('The scopes granted to the token'),
}),
},
manualCredentials: {
type: 'integration',
schema: z.object({
personalAccessToken: z.string().secret().describe('The Airtable Personal Access Token'),
}),
},
oauthPkce: {
type: 'integration',
schema: z.object({
codeVerifier: z.string().describe('The PKCE code verifier paired with the in-flight authorization request'),
createdAt: z.string().datetime().describe('The timestamp of when the code verifier was issued'),
}),
},
configuration: {
type: 'integration',
schema: z.object({
baseId: z.string().describe('The selected Airtable base ID'),
endpointUrl: z.string().optional().describe('Optional override for the Airtable API endpoint'),
}),
},
},
secrets: {
CLIENT_ID: {
description: 'The client ID of the Airtable OAuth app.',
},
CLIENT_SECRET: {
description: 'The client secret of the Airtable OAuth app.',
},
},
attributes: {
category: 'Project Management',
repo: 'botpress',
},
})
+4
View File
@@ -0,0 +1,4 @@
webhookId = to_string!(.webhookId)
webhookUrl = to_string!(.webhookUrl)
"{{ webhookUrl }}/oauth/wizard/start?state={{ webhookId }}"
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@botpresshub/airtable",
"description": "Airtable integration for Botpress",
"private": true,
"scripts": {
"build": "bp add -y && bp build",
"check:type": "tsc --noEmit",
"check:bplint": "bp lint",
"test": "vitest --run"
},
"dependencies": {
"@botpress/sdk": "workspace:*",
"airtable": "^0.12.2",
"axios": "^1.5.0"
},
"devDependencies": {
"@botpress/cli": "workspace:*",
"@botpress/common": "workspace:*",
"@botpress/sdk": "workspace:*"
}
}
@@ -0,0 +1,12 @@
import { createRecord, listRecords, updateRecord } from './record'
import { createTable, getTableRecords, updateTable } from './table'
import * as bp from '.botpress'
export default {
getTableRecords,
createTable,
updateTable,
createRecord,
updateRecord,
listRecords,
} as const satisfies bp.IntegrationProps['actions']
@@ -0,0 +1,37 @@
import { FieldSet } from 'airtable/lib/field_set'
import { AirtableClient } from '../airtable-api/airtable-client'
import type { FieldValue } from '../misc/field-schemas'
import type { IntegrationProps } from '../misc/types'
function toFieldSet(fields: FieldValue[]): FieldSet {
const result: FieldSet = {}
for (const field of fields) {
result[field.fieldNameOrId] = field.value as FieldSet[string]
}
return result
}
export const createRecord: IntegrationProps['actions']['createRecord'] = async ({ client, ctx, logger, input }) => {
const airtableClient = await AirtableClient.createFromStates({ client, ctx, logger })
const record = await airtableClient.createRecord(input.tableIdOrName, toFieldSet(input.fields))
logger.forBot().info(`Successful - Create Record - ${record.id}`)
return record
}
export const updateRecord: IntegrationProps['actions']['updateRecord'] = async ({ client, ctx, logger, input }) => {
const airtableClient = await AirtableClient.createFromStates({ client, ctx, logger })
const record = await airtableClient.updateRecord(input.tableIdOrName, input.recordId, toFieldSet(input.fields))
logger.forBot().info(`Successful - Update Record - ${record.id}`)
return record
}
export const listRecords: IntegrationProps['actions']['listRecords'] = async ({ client, ctx, logger, input }) => {
const airtableClient = await AirtableClient.createFromStates({ client, ctx, logger })
const records = await airtableClient.listRecords({
tableIdOrName: input.tableIdOrName,
filterByFormula: input.filterByFormula,
nextToken: input.nextToken,
})
logger.forBot().info(`Successful - List Records - ${input.tableIdOrName}`)
return records
}
@@ -0,0 +1,31 @@
import { AirtableClient } from '../airtable-api/airtable-client'
import type { IntegrationProps } from '../misc/types'
export const createTable: IntegrationProps['actions']['createTable'] = async ({ client, ctx, logger, input }) => {
const airtableClient = await AirtableClient.createFromStates({ client, ctx, logger })
const table = await airtableClient.createTable(input.name, input.fields, input.description)
logger.forBot().info(`Successful - Create Table - ${table.id} - ${table.name}`)
return table
}
export const updateTable: IntegrationProps['actions']['updateTable'] = async ({ client, ctx, logger, input }) => {
const airtableClient = await AirtableClient.createFromStates({ client, ctx, logger })
const table = await airtableClient.updateTable(input.tableIdOrName, input.name, input.description)
logger.forBot().info(`Successful - Update Table - ${table.id} - ${table.name}`)
return table
}
export const getTableRecords: IntegrationProps['actions']['getTableRecords'] = async ({
client,
ctx,
logger,
input,
}) => {
const airtableClient = await AirtableClient.createFromStates({ client, ctx, logger })
const records = await airtableClient.listRecords({
tableIdOrName: input.tableIdOrName,
nextToken: input.nextToken,
})
logger.forBot().info(`Successful - Get Table Records - ${input.tableIdOrName}`)
return records
}
@@ -0,0 +1,172 @@
import Airtable, { type FieldSet } from 'airtable'
import axios, { AxiosInstance } from 'axios'
import { stringify } from 'querystring'
import { handleErrorsDecorator } from '../api/error-handling'
import type { CreatableField } from '../misc/field-schemas'
import { AirtableOAuthClient } from './airtable-oauth-client'
import * as bp from '.botpress'
const handleErrors = handleErrorsDecorator
type TableResponse = {
id: string
name: string
description?: string
fields: Array<{ name: string; type: string }>
primaryFieldId: string
views: Array<{ id: string; name: string; type: string }>
}
type RecordResponse = {
id: string
fields: FieldSet
}
type CreateProps = {
client: bp.Client
ctx: bp.Context
logger: bp.Logger
}
export class AirtableClient {
private readonly _baseId: string
private readonly _base: Airtable.Base
private readonly _axiosClient: AxiosInstance
private constructor({
baseId,
accessToken,
endpointUrl,
}: {
logger: bp.Logger
baseId: string
accessToken: string
endpointUrl: string
}) {
this._baseId = baseId
// The Airtable SDK appends /v0/ itself, so strip it from the configured endpoint.
this._base = new Airtable({ apiKey: accessToken, endpointUrl: endpointUrl.replace('/v0/', '') }).base(baseId)
this._axiosClient = axios.create({
baseURL: endpointUrl,
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
})
}
public static async createFromStates({ client, ctx, logger }: CreateProps): Promise<AirtableClient> {
const oauth = new AirtableOAuthClient({ client, ctx, logger })
return AirtableClient._createNewInstance({ client, ctx, logger, oauth })
}
private static async _createNewInstance({
client,
ctx,
logger,
oauth,
}: {
client: bp.Client
ctx: bp.Context
logger: bp.Logger
oauth: AirtableOAuthClient
}): Promise<AirtableClient> {
const { accessToken } = await oauth.getAuthState()
const { baseId, endpointUrl } = await AirtableClient._getConfiguration({ client, ctx })
return new AirtableClient({
logger,
baseId,
accessToken,
endpointUrl: endpointUrl ?? 'https://api.airtable.com/v0/',
})
}
private static async _getConfiguration({
client,
ctx,
}: {
client: bp.Client
ctx: bp.Context
}): Promise<{ baseId: string; endpointUrl?: string }> {
const { state } = await client.getState({
type: 'integration',
id: ctx.integrationId,
name: 'configuration',
})
return state.payload
}
@handleErrors('Failed to test connection to Airtable')
public async testConnection(): Promise<{ id: string; email?: string; scopes?: string[] }> {
const response = await this._axiosClient.get<{ id: string; email?: string; scopes?: string[] }>('/meta/whoami')
return response.data
}
@handleErrors('Failed to list records')
public async listRecords({
tableIdOrName,
filterByFormula,
nextToken,
}: {
tableIdOrName: string
filterByFormula?: string
nextToken?: string
}): Promise<{ records: RecordResponse[]; nextToken?: string }> {
const response = await this._axiosClient.get(
`${this._baseId}/${tableIdOrName}?${stringify({ nextToken, filterByFormula })}`
)
const records = response.data?.records
if (!records) {
return { records: [], nextToken: undefined }
}
return {
records,
nextToken: response.data?.offset,
}
}
@handleErrors('Failed to create record')
public async createRecord(tableIdOrName: string, fields: FieldSet): Promise<RecordResponse> {
const record = await this._base(tableIdOrName).create(fields)
return {
id: record.id,
fields: record.fields,
}
}
@handleErrors('Failed to update record')
public async updateRecord(tableIdOrName: string, recordId: string, fields: FieldSet): Promise<RecordResponse> {
const record = await this._base(tableIdOrName).update(recordId, fields)
return {
id: record.id,
fields: record.fields,
}
}
@handleErrors('Failed to create table')
public async createTable(name: string, fields: CreatableField[], description?: string): Promise<TableResponse> {
const descriptionLimit = 20000
const validDescription = description?.slice(0, descriptionLimit)
const payload = {
name,
description: validDescription,
fields,
}
const response = await this._axiosClient.post(`/meta/bases/${this._baseId}/tables`, payload)
return response.data
}
@handleErrors('Failed to update table')
public async updateTable(tableIdOrName: string, name?: string, description?: string): Promise<TableResponse> {
const response = await this._axiosClient.patch(`/meta/bases/${this._baseId}/tables/${tableIdOrName}`, {
name,
description,
})
return response.data
}
}
@@ -0,0 +1,289 @@
import * as sdk from '@botpress/sdk'
import axios from 'axios'
import { handleErrorsDecorator as handleErrors } from '../api/error-handling'
import * as bp from '.botpress'
type AirtableTokenResponse = {
access_token: string
refresh_token: string
token_type: string
expires_in: number
refresh_expires_in: number
scope: string
}
type PrivateAuthState = {
readonly accessToken: {
readonly expiresAt: Date
readonly token: string
}
readonly refreshToken: {
readonly expiresAt: Date
readonly token: string
}
readonly scopes: string[]
}
type PublicAuthState = {
readonly accessToken: string
readonly scopes: string[]
}
const AIRTABLE_TOKEN_URL = 'https://airtable.com/oauth2/v1/token'
const MINIMUM_TOKEN_VALIDITY_SECONDS = 3_600 // 1 hour
export class AirtableOAuthClient {
private readonly _client: bp.Client
private readonly _ctx: bp.Context
private readonly _logger: bp.Logger
private readonly _clientId: string
private readonly _clientSecret: string
private _currentAuthState: PrivateAuthState | undefined = undefined
public constructor({
ctx,
client,
logger,
clientIdOverride,
clientSecretOverride,
}: {
client: bp.Client
ctx: bp.Context
logger: bp.Logger
clientIdOverride?: string
clientSecretOverride?: string
}) {
this._clientId = clientIdOverride ?? bp.secrets.CLIENT_ID
this._clientSecret = clientSecretOverride ?? bp.secrets.CLIENT_SECRET
this._client = client
this._ctx = ctx
this._logger = logger
}
@handleErrors('Failed to refresh Airtable credentials')
public async getAuthState(): Promise<PublicAuthState> {
const manualCredentials = await this._getManualCredentialsState()
if (manualCredentials && manualCredentials.personalAccessToken !== '') {
return {
accessToken: manualCredentials.personalAccessToken,
scopes: [],
}
}
await this._refreshAuthStateIfNeeded()
if (!this._currentAuthState) {
throw new sdk.RuntimeError('No credentials found')
}
return {
accessToken: this._currentAuthState.accessToken.token,
scopes: this._currentAuthState.scopes,
}
}
public readonly requestShortLivedCredentials = {
fromAuthorizationCode: async (authorizationCode: string, codeVerifier: string, redirectUri: string) => {
this._logger.forBot().debug('Exchanging authorization code for short-lived credentials...')
const response = await this._postToken({
grant_type: 'authorization_code',
code: authorizationCode,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
})
this._logger.forBot().debug('Authorization code exchanged for tokens, parsing response and saving credentials...')
this._currentAuthState = this._parseAirtableTokenResponse(response)
await this._saveOAuthCredentials()
await this._clearManualCredentials()
this._logger.debug('Successfully exchanged authorization code')
},
fromRefreshToken: async (refreshToken: string) => {
this._logger.forBot().debug('Exchanging refresh token for short-lived credentials...')
const response = await this._postToken({
grant_type: 'refresh_token',
refresh_token: refreshToken,
})
this._currentAuthState = this._parseAirtableTokenResponse(response)
await this._saveOAuthCredentials()
this._logger.debug('Successfully exchanged refresh token')
},
}
@handleErrors('Failed to save Airtable personal access token')
public async savePersonalAccessToken(personalAccessToken: string): Promise<void> {
await this._client.setState({
type: 'integration',
name: 'manualCredentials',
id: this._ctx.integrationId,
payload: { personalAccessToken },
})
await this._clearOAuthCredentials()
}
private async _postToken(body: Record<string, string>): Promise<AirtableTokenResponse> {
const basicAuth = Buffer.from(`${this._clientId}:${this._clientSecret}`).toString('base64')
const response = await axios.post<AirtableTokenResponse>(AIRTABLE_TOKEN_URL, new URLSearchParams(body).toString(), {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${basicAuth}`,
},
})
return response.data
}
private async _refreshAuthStateIfNeeded(): Promise<void> {
if (this._isTokenStillValid()) {
return
}
const credentials = await this._getOAuthCredentialsState()
if (!credentials) {
throw new sdk.RuntimeError('No credentials found')
}
await this._refreshAuth(credentials)
}
private _isTokenStillValid() {
return this._currentAuthState && this._currentAuthState.accessToken.expiresAt > this._getMinExpiryDate()
}
private _getMinExpiryDate() {
return new Date(Date.now() + MINIMUM_TOKEN_VALIDITY_SECONDS * 1000)
}
private async _getManualCredentialsState() {
return this._client
.getState({
type: 'integration',
id: this._ctx.integrationId,
name: 'manualCredentials',
})
.then(({ state }) => state.payload)
.catch(() => undefined)
}
private async _getOAuthCredentialsState() {
return this._client
.getState({
type: 'integration',
id: this._ctx.integrationId,
name: 'oAuthCredentials',
})
.then(({ state }) => state.payload)
.catch(() => undefined)
}
private async _refreshAuth(credentials: bp.states.oAuthCredentials.OAuthCredentials['payload']) {
const accessTokenExpiresAt = new Date(credentials.expiresAt)
const refreshTokenExpiresAt = new Date(credentials.refreshExpiresAt)
if (refreshTokenExpiresAt <= new Date()) {
throw new sdk.RuntimeError('Airtable refresh token has expired. Please re-run the integration setup wizard.')
}
if (accessTokenExpiresAt > new Date()) {
this._currentAuthState = {
accessToken: {
expiresAt: accessTokenExpiresAt,
token: credentials.accessToken,
},
refreshToken: {
expiresAt: refreshTokenExpiresAt,
token: credentials.refreshToken,
},
scopes: credentials.scopes,
}
return
}
await this.requestShortLivedCredentials.fromRefreshToken(credentials.refreshToken)
}
private _parseAirtableTokenResponse(response: AirtableTokenResponse): PrivateAuthState {
if (
!response.access_token ||
!response.refresh_token ||
!response.expires_in ||
!response.refresh_expires_in ||
!response.scope
) {
this._logger.forBot().error('Airtable OAuth response is missing required fields')
throw new sdk.RuntimeError('OAuth response is missing required fields')
}
if (response.expires_in < MINIMUM_TOKEN_VALIDITY_SECONDS) {
this._logger.forBot().error(`Airtable OAuth response has invalid expires_in=${response.expires_in}`)
throw new sdk.RuntimeError('OAuth response has an invalid expiration time')
}
const now = Date.now()
const accessTokenExpiresAt = new Date(now + response.expires_in * 1000)
const refreshTokenExpiresAt = new Date(now + response.refresh_expires_in * 1000)
return {
accessToken: {
expiresAt: accessTokenExpiresAt,
token: response.access_token,
},
refreshToken: {
expiresAt: refreshTokenExpiresAt,
token: response.refresh_token,
},
scopes: response.scope.split(' '),
} as const
}
private async _clearManualCredentials() {
await this._client.setState({
type: 'integration',
name: 'manualCredentials',
id: this._ctx.integrationId,
payload: { personalAccessToken: '' },
})
}
private async _clearOAuthCredentials() {
const epoch = new Date(0).toISOString()
await this._client.setState({
type: 'integration',
name: 'oAuthCredentials',
id: this._ctx.integrationId,
payload: {
accessToken: '',
refreshToken: '',
expiresAt: epoch,
refreshExpiresAt: epoch,
scopes: [],
},
})
}
private async _saveOAuthCredentials() {
if (!this._currentAuthState) {
throw new sdk.RuntimeError('No credentials to save')
}
await this._client.setState({
type: 'integration',
name: 'oAuthCredentials',
id: this._ctx.integrationId,
payload: {
accessToken: this._currentAuthState.accessToken.token,
refreshToken: this._currentAuthState.refreshToken.token,
expiresAt: this._currentAuthState.accessToken.expiresAt.toISOString(),
refreshExpiresAt: this._currentAuthState.refreshToken.expiresAt.toISOString(),
scopes: this._currentAuthState.scopes,
},
})
}
}
@@ -0,0 +1,36 @@
import { createAsyncFnWrapperWithErrorRedaction, createErrorHandlingDecorator } from '@botpress/common'
import * as sdk from '@botpress/sdk'
import axios from 'axios'
// Note: AirtableError from the `airtable` npm package does NOT extend Error.
// The createAsyncFnWrapperWithErrorRedaction wrapper converts it to
// new Error(airtableError.toString()), which produces a message like:
// "Some message(ERROR_CODE)[Http code 422]"
// This info is preserved in error.message and surfaced to the user below.
export const redactAirtableError = (error: Error, customErrorMessage: string): sdk.RuntimeError => {
if (error instanceof sdk.RuntimeError) {
return error
}
if (axios.isAxiosError(error)) {
const status = error.response?.status
const data = error.response?.data as { error?: { message?: string }; message?: string } | undefined
const apiMessage = data?.error?.message ?? data?.message
return new sdk.RuntimeError(
`${customErrorMessage}${status ? ` [HTTP ${status}]` : ''}${apiMessage ? `: ${apiMessage}` : ''}`
)
}
// For AirtableError (converted to Error via toString()) and other errors,
// include the original message which contains useful API error details
if (error.message && error.message !== customErrorMessage) {
return new sdk.RuntimeError(`${customErrorMessage}: ${error.message}`)
}
return new sdk.RuntimeError(customErrorMessage)
}
export const wrapAsyncFnWithTryCatch = createAsyncFnWrapperWithErrorRedaction(redactAirtableError)
export const handleErrorsDecorator = createErrorHandlingDecorator(wrapAsyncFnWithTryCatch)
+30
View File
@@ -0,0 +1,30 @@
import { isOAuthWizardUrl } from '@botpress/common/src/oauth-wizard'
import * as sdk from '@botpress/sdk'
import actions from './actions'
import { AirtableClient } from './airtable-api/airtable-client'
import { oauthWizardHandler } from './oauth-wizard'
import * as botpress from '.botpress'
export default new botpress.Integration({
register: async ({ client, ctx, logger }) => {
try {
const airtableClient = await AirtableClient.createFromStates({ client, ctx, logger })
const { id } = await airtableClient.testConnection()
await client.configureIntegration({ identifier: id })
} catch (thrown) {
const message = thrown instanceof Error ? thrown.message : String(thrown)
throw new sdk.RuntimeError(`Failed to connect to Airtable. Re-run the setup wizard. (${message})`)
}
logger.forBot().info('Connection to Airtable successful')
},
unregister: async () => {},
actions,
channels: {},
handler: async (props) => {
if (isOAuthWizardUrl(props.req.path)) {
return await oauthWizardHandler(props)
}
return
},
})
@@ -0,0 +1,94 @@
import { z } from '@botpress/sdk'
import { creatableFieldSchema, fieldValueSchema } from './field-schemas'
import { tableSchema, recordSchema } from './sub-schemas'
export const getTableRecordsInputSchema = z.object({
tableIdOrName: z
.string()
.describe('The ID or Name of the table (e.g. tblFnqcm4zLVKn85A or articles)')
.title('Table ID or Name'),
nextToken: z.string().optional().describe('The next page token (Optional)').title('Next Token'),
})
export const getTableRecordsOutputSchema = z.object({
records: z.array(recordSchema).describe('Array of single record with field and cell values').title('Records'),
nextToken: z.string().optional().describe('The next page token (Optional)').title('Next Token'),
})
export const listRecordsInputSchema = z.object({
tableIdOrName: z
.string()
.describe('The ID or Name of the table (e.g. tblFnqcm4zLVKn85A or articles)')
.title('Table ID or Name'),
nextToken: z.string().optional().describe('The next page token (Optional)').title('Next Token'),
filterByFormula: z
.string()
.optional()
.describe('filter to apply to the list records (See https://support.airtable.com/docs/formula-field-reference)')
.title('Filter By Formula'),
})
export const listRecordsOutputSchema = z
.object({
records: z.array(recordSchema).describe('Array of single record with field and cell values').title('Records'),
nextToken: z.string().optional().describe('The next page token (Optional)').title('Next Token'),
})
.passthrough()
export const createTableInputSchema = z.object({
name: z.string().describe('Name of the Table (e.g. MyTable)').title('Name'),
fields: z
.array(creatableFieldSchema)
.min(1)
.describe(
'The fields to create in the table. Each field requires a name and type, and some types require additional options.'
)
.title('Fields'),
description: z
.string()
.optional()
.describe('Description of the Table (e.g. This is my table) (Optional)')
.title('Description'),
})
export const createTableOutputSchema = tableSchema.passthrough()
export const updateTableInputSchema = z.object({
tableIdOrName: z
.string()
.describe('The ID or Name of the table (e.g. tblFnqcm4zLVKn85A or articles)')
.title('Table ID or Name'),
name: z.string().optional().describe('Name of the Table (e.g. MyTable) (Optional)').title('Name'),
description: z
.string()
.optional()
.describe('Description of the Table (e.g. This is my table) (Optional)')
.title('Description'),
})
export const updateTableOutputSchema = tableSchema.passthrough()
export const createRecordInputSchema = z.object({
tableIdOrName: z
.string()
.describe('The ID or Name of the table (e.g. tblFnqcm4zLVKn85A or articles)')
.title('Table ID or Name'),
fields: z.array(fieldValueSchema).min(1).describe('The fields and their values for the new record').title('Fields'),
})
export const createRecordOutputSchema = recordSchema.passthrough()
export const updateRecordInputSchema = z.object({
tableIdOrName: z
.string()
.describe('The ID or Name of the table (e.g. tblFnqcm4zLVKn85A or articles)')
.title('Table ID or Name'),
recordId: z.string().describe('The ID of the Record to be updated').title('Record ID'),
fields: z
.array(fieldValueSchema)
.min(1)
.describe('The fields and their values for the record to be updated')
.title('Fields'),
})
export const updateRecordOutputSchema = recordSchema.passthrough()
@@ -0,0 +1,564 @@
import { z } from '@botpress/sdk'
const airtableFieldType = z.enum([
'aiText',
'autoNumber',
'barcode',
'button',
'checkbox',
'count',
'createdBy',
'createdTime',
'currency',
'date',
'dateTime',
'duration',
'email',
'externalSyncSource',
'formula',
'lastModifiedBy',
'lastModifiedTime',
'multilineText',
'multipleAttachments',
'multipleCollaborators',
'multipleLookupValues',
'multipleRecordLinks',
'multipleSelects',
'number',
'percent',
'phoneNumber',
'rating',
'richText',
'rollup',
'singleCollaborator',
'singleLineText',
'singleSelect',
'url',
])
const baseField = z.object({
name: z.string().min(1).title('Field Name').describe('The name of the field'),
description: z.string().optional().title('Description').describe('The description of the field'),
})
// Shared enums
const baseColors = [
'greenBright',
'tealBright',
'cyanBright',
'blueBright',
'purpleBright',
'pinkBright',
'redBright',
'orangeBright',
'yellowBright',
'grayBright',
] as const
const extendedColors = [
...baseColors,
'blueLight',
'blueDark',
'cyanLight',
'cyanDark',
'tealLight',
'tealDark',
'greenLight',
'greenDark',
'yellowLight',
'yellowDark',
'orangeLight',
'orangeDark',
'redLight',
'redDark',
'pinkLight',
'pinkDark',
'purpleLight',
'purpleDark',
'grayLight',
'grayDark',
] as const
const ratingIcons = ['star', 'heart', 'thumbsUp', 'flag', 'dot'] as const
const checkboxIcons = [...ratingIcons, 'check', 'xCheckbox'] as const
// Shared sub-schemas
const dateFormatName = z.enum(['local', 'friendly', 'us', 'european', 'iso'])
const dateFormatSchema = z.object({
name: dateFormatName
.title('Format Name')
.describe(
'Date format preset: local (locale-dependent), friendly (LL e.g. "January 1, 2020"), us (M/D/YYYY), european (D/M/YYYY), iso (YYYY-MM-DD)'
),
format: z.string().optional().title('Format String').describe('Read-only format string, auto-set based on name'),
})
const timeFormatName = z.enum(['12hour', '24hour'])
const timeFormatSchema = z.object({
name: timeFormatName
.title('Format Name')
.describe('Time format preset: 12hour (h:mma e.g. "1:00pm"), 24hour (HH:mm e.g. "13:00")'),
format: z.string().optional().title('Format String').describe('Read-only format string, auto-set based on name'),
})
const choiceSchema = z.object({
name: z.string().title('Name').describe('Choice name'),
color: z.enum(extendedColors).optional().title('Color').describe('Choice color'),
})
// Simple fields (no options)
const singleLineTextField = baseField.extend({
type: airtableFieldType.extract(['singleLineText']),
})
const emailField = baseField.extend({
type: airtableFieldType.extract(['email']),
})
const urlField = baseField.extend({
type: airtableFieldType.extract(['url']),
})
const multilineTextField = baseField.extend({
type: airtableFieldType.extract(['multilineText']),
})
const phoneNumberField = baseField.extend({
type: airtableFieldType.extract(['phoneNumber']),
})
const richTextField = baseField.extend({
type: airtableFieldType.extract(['richText']),
})
const singleCollaboratorField = baseField.extend({
type: airtableFieldType.extract(['singleCollaborator']),
})
const multipleCollaboratorsField = baseField.extend({
type: airtableFieldType.extract(['multipleCollaborators']),
})
const autoNumberField = baseField.extend({
type: airtableFieldType.extract(['autoNumber']),
})
const barcodeField = baseField.extend({
type: airtableFieldType.extract(['barcode']),
})
const buttonField = baseField.extend({
type: airtableFieldType.extract(['button']),
})
const countField = baseField.extend({
type: airtableFieldType.extract(['count']),
})
const createdByField = baseField.extend({
type: airtableFieldType.extract(['createdBy']),
})
const externalSyncSourceField = baseField.extend({
type: airtableFieldType.extract(['externalSyncSource']),
})
const formulaField = baseField.extend({
type: airtableFieldType.extract(['formula']),
})
const lastModifiedByField = baseField.extend({
type: airtableFieldType.extract(['lastModifiedBy']),
})
const multipleLookupValuesField = baseField.extend({
type: airtableFieldType.extract(['multipleLookupValues']),
})
const aiTextField = baseField.extend({
type: airtableFieldType.extract(['aiText']),
})
// Fields with options
const multipleAttachmentsField = baseField.extend({
type: airtableFieldType.extract(['multipleAttachments']),
options: z
.object({
isReversed: z.boolean().optional().describe('Whether attachments are in reverse order'),
})
.optional()
.title('Options')
.describe('Attachment field options'),
})
const checkboxField = baseField.extend({
type: airtableFieldType.extract(['checkbox']),
options: z
.object({
color: z.enum(baseColors).title('Color').describe('Checkbox color'),
icon: z.enum(checkboxIcons).title('Icon').describe('Checkbox icon'),
})
.title('Options')
.describe('Checkbox display options'),
})
const currencyField = baseField.extend({
type: airtableFieldType.extract(['currency']),
options: z
.object({
precision: z.number().min(0).max(7).title('Precision').describe('Number of decimal places (0-7)'),
symbol: z.string().title('Symbol').describe('Currency symbol (e.g. "$")'),
})
.title('Options')
.describe('Currency field options'),
})
const dateField = baseField.extend({
type: airtableFieldType.extract(['date']),
options: z
.object({
dateFormat: dateFormatSchema.title('Date Format').describe('Date format configuration'),
})
.title('Options')
.describe('Date field options'),
})
const dateTimeField = baseField.extend({
type: airtableFieldType.extract(['dateTime']),
options: z
.object({
dateFormat: dateFormatSchema.title('Date Format').describe('Date format configuration'),
timeFormat: timeFormatSchema.title('Time Format').describe('Time format configuration'),
timeZone: z.string().title('Time Zone').describe('Time zone identifier (e.g. "America/New_York")'),
})
.title('Options')
.describe('DateTime field options'),
})
const durationField = baseField.extend({
type: airtableFieldType.extract(['duration']),
options: z
.object({
durationFormat: z
.enum(['h:mm', 'h:mm:ss', 'h:mm:ss.S', 'h:mm:ss.SS', 'h:mm:ss.SSS'])
.title('Duration Format')
.describe('Duration display format'),
})
.title('Options')
.describe('Duration field options'),
})
const numberField = baseField.extend({
type: airtableFieldType.extract(['number']),
options: z
.object({
precision: z.number().min(0).max(8).title('Precision').describe('Number of decimal places (0-8)'),
})
.title('Options')
.describe('Number field options'),
})
const percentField = baseField.extend({
type: airtableFieldType.extract(['percent']),
options: z
.object({
precision: z.number().min(0).max(8).title('Precision').describe('Number of decimal places (0-8)'),
})
.title('Options')
.describe('Percent field options'),
})
const ratingField = baseField.extend({
type: airtableFieldType.extract(['rating']),
options: z
.object({
max: z.number().min(1).max(10).title('Max').describe('Maximum rating value (1-10)'),
color: z.enum(baseColors).title('Color').describe('Rating color'),
icon: z.enum(ratingIcons).title('Icon').describe('Rating icon'),
})
.title('Options')
.describe('Rating field options'),
})
const singleSelectField = baseField.extend({
type: airtableFieldType.extract(['singleSelect']),
options: z
.object({
choices: z.array(choiceSchema).title('Choices').describe('Available choices'),
})
.title('Options')
.describe('Single select options'),
})
const multipleSelectsField = baseField.extend({
type: airtableFieldType.extract(['multipleSelects']),
options: z
.object({
choices: z.array(choiceSchema).title('Choices').describe('Available choices'),
})
.title('Options')
.describe('Multiple select options'),
})
const multipleRecordLinksField = baseField.extend({
type: airtableFieldType.extract(['multipleRecordLinks']),
options: z
.object({
linkedTableId: z.string().title('Linked Table ID').describe('ID of the linked table'),
prefersSingleRecordLink: z
.boolean()
.optional()
.title('Prefers Single Record Link')
.describe('Whether to prefer a single record link'),
inverseLinkFieldId: z.string().optional().title('Inverse Link Field ID').describe('ID of the inverse link field'),
viewIdForRecordSelection: z
.string()
.optional()
.title('View ID for Record Selection')
.describe('ID of the view for record selection'),
isReversed: z.boolean().optional().title('Is Reversed').describe('Whether the link is reversed'),
})
.title('Options')
.describe('Record link options'),
})
const createdTimeField = baseField.extend({
type: airtableFieldType.extract(['createdTime']),
options: z
.object({
result: z.object({}).passthrough().optional().title('Result').describe('Result type configuration'),
})
.optional()
.title('Options')
.describe('Created time field options'),
})
const lastModifiedTimeField = baseField.extend({
type: airtableFieldType.extract(['lastModifiedTime']),
options: z
.object({
result: z.object({}).passthrough().optional().title('Result').describe('Result type configuration'),
})
.optional()
.title('Options')
.describe('Last modified time field options'),
})
const rollupField = baseField.extend({
type: airtableFieldType.extract(['rollup']),
options: z
.object({
fieldIdInLinkedTable: z
.string()
.title('Field ID in Linked Table')
.describe('ID of the field in the linked table'),
recordLinkFieldId: z.string().title('Record Link Field ID').describe('ID of the record link field'),
result: z.object({}).passthrough().optional().title('Result').describe('Result type configuration'),
referencedFieldIds: z
.array(z.string())
.optional()
.title('Referenced Field IDs')
.describe('IDs of referenced fields'),
})
.optional()
.title('Options')
.describe('Rollup field options'),
})
// Full discriminated union (all field types, for API responses)
export const airtableFieldSchema = z.discriminatedUnion('type', [
singleLineTextField,
emailField,
urlField,
multilineTextField,
phoneNumberField,
richTextField,
singleCollaboratorField,
multipleCollaboratorsField,
multipleAttachmentsField,
autoNumberField,
barcodeField,
buttonField,
checkboxField,
countField,
createdByField,
createdTimeField,
currencyField,
dateField,
dateTimeField,
durationField,
externalSyncSourceField,
formulaField,
lastModifiedByField,
lastModifiedTimeField,
multipleRecordLinksField,
multipleSelectsField,
numberField,
percentField,
ratingField,
rollupField,
singleSelectField,
multipleLookupValuesField,
aiTextField,
])
// Creatable subset (excludes read-only/computed field types)
export const creatableFieldSchema = z.discriminatedUnion('type', [
singleLineTextField,
emailField,
urlField,
multilineTextField,
phoneNumberField,
richTextField,
singleCollaboratorField,
multipleCollaboratorsField,
multipleAttachmentsField,
checkboxField,
currencyField,
dateField,
dateTimeField,
durationField,
multipleRecordLinksField,
multipleSelectsField,
numberField,
percentField,
ratingField,
singleSelectField,
])
export type AirtableField = z.infer<typeof airtableFieldSchema>
export type CreatableField = z.infer<typeof creatableFieldSchema>
// Field value schemas (for record create/update — cell values per field type)
const fieldValueBase = z.object({
fieldNameOrId: z.string().min(1).title('Field Name or ID').describe('The name or ID of the field'),
})
const stringFieldValue = (type: (typeof airtableFieldType.options)[number], description: string) =>
fieldValueBase.extend({
type: airtableFieldType.extract([type]),
value: z.string().title('Value').describe(description),
})
const numberFieldValue = (type: (typeof airtableFieldType.options)[number], description: string) =>
fieldValueBase.extend({
type: airtableFieldType.extract([type]),
value: z.number().title('Value').describe(description),
})
const singleLineTextValue = stringFieldValue('singleLineText', 'Text value')
const emailValue = stringFieldValue('email', 'Email address')
const urlValue = stringFieldValue('url', 'URL value')
const multilineTextValue = stringFieldValue('multilineText', 'Multi-line text value')
const richTextValue = stringFieldValue('richText', 'Rich text value (supports Airtable-flavored markdown)')
const phoneNumberValue = stringFieldValue('phoneNumber', 'Phone number')
const singleSelectValue = stringFieldValue('singleSelect', 'Option name')
const dateValue = stringFieldValue('date', 'ISO date string (e.g. "2025-03-09")')
const dateTimeValue = stringFieldValue('dateTime', 'ISO datetime string (e.g. "2025-03-09T14:30:00.000Z")')
const numberValue = numberFieldValue('number', 'Numeric value')
const percentValue = numberFieldValue('percent', 'Percent as decimal (e.g. 0.5 for 50%)')
const currencyValue = numberFieldValue('currency', 'Currency amount')
const durationValue = numberFieldValue('duration', 'Duration in seconds')
const ratingValue = fieldValueBase.extend({
type: airtableFieldType.extract(['rating']),
value: z.number().min(1).max(10).title('Value').describe('Rating value (1 to max configured)'),
})
const checkboxValue = fieldValueBase.extend({
type: airtableFieldType.extract(['checkbox']),
value: z.boolean().title('Value').describe('Checked state'),
})
const multipleSelectsValue = fieldValueBase.extend({
type: airtableFieldType.extract(['multipleSelects']),
value: z.array(z.string()).title('Value').describe('Selected option names'),
})
const multipleRecordLinksValue = fieldValueBase.extend({
type: airtableFieldType.extract(['multipleRecordLinks']),
value: z.array(z.string()).title('Value').describe('Linked record IDs (e.g. ["recABC123"])'),
})
const multipleAttachmentsValue = fieldValueBase.extend({
type: airtableFieldType.extract(['multipleAttachments']),
value: z
.array(
z.object({
url: z.string().title('URL').describe('Attachment URL'),
filename: z.string().optional().title('Filename').describe('Attachment filename'),
})
)
.title('Value')
.describe('Attachments to upload'),
})
const barcodeValue = fieldValueBase.extend({
type: airtableFieldType.extract(['barcode']),
value: z
.object({
text: z.string().title('Text').describe('Barcode text value'),
})
.title('Value')
.describe('Barcode value'),
})
const singleCollaboratorValue = fieldValueBase.extend({
type: airtableFieldType.extract(['singleCollaborator']),
value: z
.object({
id: z.string().title('User ID').describe('Collaborator user ID'),
})
.title('Value')
.describe('Collaborator'),
})
const multipleCollaboratorsValue = fieldValueBase.extend({
type: airtableFieldType.extract(['multipleCollaborators']),
value: z
.array(
z.object({
id: z.string().title('User ID').describe('Collaborator user ID'),
})
)
.title('Value')
.describe('Collaborators'),
})
// Writable field value discriminated union (for createRecord/updateRecord)
export const fieldValueSchema = z.discriminatedUnion('type', [
singleLineTextValue,
emailValue,
urlValue,
multilineTextValue,
richTextValue,
phoneNumberValue,
singleSelectValue,
dateValue,
dateTimeValue,
numberValue,
percentValue,
currencyValue,
durationValue,
ratingValue,
checkboxValue,
multipleSelectsValue,
multipleRecordLinksValue,
multipleAttachmentsValue,
barcodeValue,
singleCollaboratorValue,
multipleCollaboratorsValue,
])
export type FieldValue = z.infer<typeof fieldValueSchema>
@@ -0,0 +1,32 @@
import { z } from '@botpress/sdk'
const fieldSchema = z
.object({
name: z.string().title('Name').describe('Field name'),
type: z.string().title('Type').describe('Field type'),
})
.passthrough()
const viewSchema = z
.object({
id: z.string().title('ID').describe('View ID'),
name: z.string().title('Name').describe('View name'),
type: z.string().title('Type').describe('View type'),
})
.passthrough()
const tableSchema = z.object({
description: z.string().optional().describe('The description of the table').title('description'),
fields: z.array(fieldSchema).describe('The fields of the table').title('Fields'),
id: z.string().describe('The ID of the table').title('ID'),
name: z.string().describe('The name of the table').title('Name'),
primaryFieldId: z.string().describe('The first column in the table and every view').title('Primary Field ID'),
views: z.array(viewSchema).describe('The views of the table').title('Views'),
})
const recordSchema = z.object({
fields: z.record(z.any()).describe('The fields of the Record').title('Fields'),
id: z.string().describe('The ID of the Record').title('ID'),
})
export { tableSchema, recordSchema }
+12
View File
@@ -0,0 +1,12 @@
import type * as bp from '.botpress'
export type Configuration = bp.configuration.Configuration
export type IntegrationProps = bp.IntegrationProps
export type IntegrationCtx = bp.Context
export type RegisterFunction = IntegrationProps['register']
export type UnregisterFunction = IntegrationProps['unregister']
export type Channels = IntegrationProps['channels']
export type Handler = IntegrationProps['handler']
export type { CreatableField } from './field-schemas'
export type TableFields = import('./field-schemas').CreatableField[]
@@ -0,0 +1,23 @@
import { isOAuthWizardUrl, getInterstitialUrl, generateRedirection } from '@botpress/common/src/oauth-wizard'
import * as wizard from './wizard'
import * as bp from '.botpress'
export const oauthWizardHandler: bp.IntegrationProps['handler'] = async (props) => {
const { req, logger } = props
if (!isOAuthWizardUrl(req.path)) {
return {
status: 404,
body: 'Invalid OAuth wizard endpoint',
}
}
try {
return await wizard.handler(props)
} catch (thrown: unknown) {
const error = thrown instanceof Error ? thrown : Error(String(thrown))
const errorMessage = 'OAuth wizard error: ' + error.message
logger.forBot().error(errorMessage)
return generateRedirection(getInterstitialUrl(false, errorMessage))
}
}
@@ -0,0 +1,224 @@
import * as oauthWizard from '@botpress/common/src/oauth-wizard'
import { Response, z } from '@botpress/sdk'
import axios from 'axios'
import * as crypto from 'crypto'
import { AirtableOAuthClient } from '../airtable-api/airtable-oauth-client'
import * as bp from '.botpress'
type WizardHandler = oauthWizard.WizardStepHandler<bp.HandlerProps>
const REQUIRED_AIRTABLE_SCOPES = ['data.records:read', 'data.records:write', 'schema.bases:read', 'schema.bases:write']
const _getRedirectUri = () => `${process.env.BP_WEBHOOK_URL}/oauth/wizard/oauth-callback`
const _generateCodeVerifier = (): string => crypto.randomBytes(64).toString('base64url')
const _generateCodeChallenge = (verifier: string): string =>
crypto.createHash('sha256').update(verifier).digest('base64url')
const _buildAirtableAuthorizeUrl = ({
codeChallenge,
webhookId,
}: {
codeChallenge: string
webhookId: string
}): string => {
const params = new URLSearchParams({
client_id: bp.secrets.CLIENT_ID,
redirect_uri: _getRedirectUri(),
response_type: 'code',
scope: REQUIRED_AIRTABLE_SCOPES.join(' '),
state: webhookId,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
})
return `https://airtable.com/oauth2/v1/authorize?${params.toString()}`
}
const _manualCredentialsSchema = z.object({
personalAccessToken: z
.string()
.secret()
.title('Personal Access Token')
.describe('An Airtable Personal Access Token with access to the base you want to use'),
baseId: z.string().title('Base ID').describe('The ID of the Airtable base (starts with "app")'),
endpointUrl: z
.string()
.optional()
.title('Endpoint URL')
.describe('Optional override for the Airtable API endpoint (default: https://api.airtable.com/v0/)'),
})
const _manualCredentialsForm = {
pageTitle: 'Airtable Personal Access Token',
htmlOrMarkdownPageContents:
'Enter your Airtable Personal Access Token and the ID of the base you want to use.<br>' +
'You can create a token in the <a href="https://airtable.com/create/tokens" target="_blank">Airtable developer hub</a>.',
schema: _manualCredentialsSchema,
nextStepId: 'save-manual-credentials',
}
export const handler = async (props: bp.HandlerProps): Promise<Response> => {
const wizard = new oauthWizard.OAuthWizardBuilder(props)
.addStep({ id: 'start', handler: _startHandler })
.addStep({ id: 'route-choice', handler: _routeChoiceHandler })
.addStep({ id: 'oauth-redirect', handler: _oauthRedirectHandler })
.addStep({ id: 'oauth-callback', handler: _oauthCallbackHandler })
.addStep({ id: 'pick-base', handler: _pickBaseHandler })
.addStep({ id: 'save-base', handler: _saveBaseHandler })
.addStep({ id: 'get-manual-credentials', handler: _getManualCredentialsHandler })
.addStep({ id: 'save-manual-credentials', handler: _saveManualCredentialsHandler })
.build()
return await wizard.handleRequest()
}
const _startHandler: WizardHandler = ({ responses }) => {
return responses.displayChoices({
pageTitle: 'Airtable Integration Setup',
htmlOrMarkdownPageContents: 'Choose how you would like to configure your Airtable integration:',
choices: [
{ label: 'Connect with OAuth', value: 'oauth' },
{ label: 'Use a Personal Access Token', value: 'manual' },
],
nextStepId: 'route-choice',
})
}
const _routeChoiceHandler: WizardHandler = ({ selectedChoice, responses }) => {
switch (selectedChoice) {
case 'manual':
return responses.redirectToStep('get-manual-credentials')
case 'oauth':
default:
return responses.redirectToStep('oauth-redirect')
}
}
const _oauthRedirectHandler: WizardHandler = async ({ ctx, client, responses }) => {
const codeVerifier = _generateCodeVerifier()
const codeChallenge = _generateCodeChallenge(codeVerifier)
await client.setState({
type: 'integration',
name: 'oauthPkce',
id: ctx.integrationId,
payload: { codeVerifier, createdAt: new Date().toISOString() },
})
return responses.redirectToExternalUrl(_buildAirtableAuthorizeUrl({ codeChallenge, webhookId: ctx.webhookId }))
}
const _oauthCallbackHandler: WizardHandler = async ({ ctx, client, logger, responses, query }) => {
const code = query.get('code')
if (!code) {
return responses.endWizard({ success: false, errorMessage: 'Airtable did not return an authorization code' })
}
const state = query.get('state')
if (!state || state !== ctx.webhookId) {
return responses.endWizard({ success: false, errorMessage: 'Invalid OAuth state parameter' })
}
const { state: pkceState } = await client.getState({
type: 'integration',
id: ctx.integrationId,
name: 'oauthPkce',
})
const codeVerifier = pkceState.payload.codeVerifier
const oauth = new AirtableOAuthClient({ client, ctx, logger })
await oauth.requestShortLivedCredentials.fromAuthorizationCode(code, codeVerifier, _getRedirectUri())
// Sentinel: schema requires non-null payload; epoch timestamp marks the verifier as consumed.
await client.setState({
type: 'integration',
name: 'oauthPkce',
id: ctx.integrationId,
payload: { codeVerifier: '', createdAt: new Date(0).toISOString() },
})
return responses.redirectToStep('pick-base')
}
const _pickBaseHandler: WizardHandler = async ({ ctx, client, logger, responses }) => {
const oauth = new AirtableOAuthClient({ client, ctx, logger })
const { accessToken } = await oauth.getAuthState()
const response = await axios.get<{ bases: Array<{ id: string; name: string; permissionLevel: string }> }>(
'https://api.airtable.com/v0/meta/bases',
{ headers: { Authorization: `Bearer ${accessToken}` } }
)
const bases = response.data.bases ?? []
if (bases.length === 0) {
return responses.endWizard({
success: false,
errorMessage: 'No Airtable bases were found for this account',
})
}
if (bases.length === 1) {
await client.setState({
type: 'integration',
name: 'configuration',
id: ctx.integrationId,
payload: { baseId: bases[0]!.id },
})
return responses.endWizard({ success: true })
}
return responses.displayChoices({
pageTitle: 'Select an Airtable Base',
htmlOrMarkdownPageContents: 'Pick the base you want this integration to use.',
choices: bases.map((base) => ({ label: base.name, value: base.id })),
nextStepId: 'save-base',
})
}
const _saveBaseHandler: WizardHandler = async ({ ctx, client, selectedChoice, responses }) => {
if (!selectedChoice) {
return responses.redirectToStep('pick-base')
}
await client.setState({
type: 'integration',
name: 'configuration',
id: ctx.integrationId,
payload: { baseId: selectedChoice },
})
return responses.endWizard({ success: true })
}
const _getManualCredentialsHandler: WizardHandler = ({ responses }) => {
return responses.displayForm(_manualCredentialsForm)
}
const _saveManualCredentialsHandler: WizardHandler = async ({ ctx, client, logger, formValues, responses }) => {
if (!formValues) {
return responses.redirectToStep('get-manual-credentials')
}
const parsed = _manualCredentialsSchema.safeParse(formValues)
if (!parsed.success) {
return responses.displayForm({
..._manualCredentialsForm,
errors: parsed.error,
previousValues: formValues as z.input<typeof _manualCredentialsSchema>,
})
}
const { personalAccessToken, baseId, endpointUrl } = parsed.data
await client.setState({
type: 'integration',
name: 'configuration',
id: ctx.integrationId,
payload: { baseId, endpointUrl: endpointUrl || undefined },
})
const oauth = new AirtableOAuthClient({ client, ctx, logger })
await oauth.savePersonalAccessToken(personalAccessToken)
return responses.endWizard({ success: true })
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist",
"experimentalDecorators": true,
"jsx": "react-jsx",
"jsxImportSource": "preact"
},
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
}
+2
View File
@@ -0,0 +1,2 @@
import config from '../../vitest.config'
export default config