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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 45 additions & 12 deletions integrations/airtable/integration.definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,14 @@ export default new IntegrationDefinition({
title: 'Airtable',
description:
'Access and manage Airtable data to allow your chatbot to retrieve details, update records, and organize information.',
version: '2.0.0',
version: '3.0.0',
readme: 'hub.md',
icon: 'icon.svg',
configuration: {
schema: z.object({
accessToken: z.string().describe('Personal Access Token').title('Personal Access Token'),
baseId: z.string().describe('Base ID').title('Base ID'),
endpointUrl: z
.string()
.optional()
.default('https://api.airtable.com/v0/')
.describe('API endpoint to hit (Default: https://api.airtable.com/v0/)')
.title('Endpoint Url'),
}),
identifier: {
linkTemplateScript: 'linkTemplate.vrl',
},
schema: z.object({}),
},
channels: {},
user: {
Expand Down Expand Up @@ -101,7 +95,46 @@ export default new IntegrationDefinition({
},
},
events: {},
states: {},
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',
Expand Down
4 changes: 4 additions & 0 deletions integrations/airtable/linkTemplate.vrl
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
webhookId = to_string!(.webhookId)
webhookUrl = to_string!(.webhookUrl)

"{{ webhookUrl }}/oauth/wizard/start?state={{ webhookId }}"
8 changes: 4 additions & 4 deletions integrations/airtable/src/actions/record.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { FieldSet } from 'airtable/lib/field_set'
import { AirtableApi } from '../client'
import { AirtableClient } from '../airtable-api/airtable-client'
import type { FieldValue } from '../misc/field-schemas'
import type { IntegrationProps } from '../misc/types'

Expand All @@ -12,21 +12,21 @@ function toFieldSet(fields: FieldValue[]): FieldSet {
}

export const createRecord: IntegrationProps['actions']['createRecord'] = async ({ client, ctx, logger, input }) => {
const airtableClient = new AirtableApi({ client, ctx, logger })
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 = new AirtableApi({ client, ctx, logger })
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 = new AirtableApi({ client, ctx, logger })
const airtableClient = await AirtableClient.createFromStates({ client, ctx, logger })
const records = await airtableClient.listRecords({
tableIdOrName: input.tableIdOrName,
filterByFormula: input.filterByFormula,
Expand Down
8 changes: 4 additions & 4 deletions integrations/airtable/src/actions/table.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { AirtableApi } from '../client'
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 = new AirtableApi({ client, ctx, logger })
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 = new AirtableApi({ client, ctx, logger })
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
Expand All @@ -21,7 +21,7 @@ export const getTableRecords: IntegrationProps['actions']['getTableRecords'] = a
logger,
input,
}) => {
const airtableClient = new AirtableApi({ client, ctx, logger })
const airtableClient = await AirtableClient.createFromStates({ client, ctx, logger })
const records = await airtableClient.listRecords({
tableIdOrName: input.tableIdOrName,
nextToken: input.nextToken,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import Airtable, { type FieldSet } from 'airtable'
import axios, { AxiosInstance, AxiosResponse } from 'axios'
import axios, { AxiosInstance } from 'axios'
import { stringify } from 'querystring'
import { handleErrorsDecorator } from './api/error-handling'
import type { CreatableField } from './misc/field-schemas'
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
Expand All @@ -20,33 +22,85 @@ type RecordResponse = {
fields: FieldSet
}

export class AirtableApi {
private _base: Airtable.Base
private _axiosClient: AxiosInstance
private _baseId: string
private _logger: bp.Logger

public constructor({ ctx, logger }: bp.CommonHandlerProps) {
this._logger = logger
const endpointUrl = ctx.configuration.endpointUrl || 'https://api.airtable.com/v0/'
const apiKey = ctx.configuration.accessToken
const baseId = ctx.configuration.baseId
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

// This split is done because the Airtable SDK appends /v0/ path itself
this._base = new Airtable({ apiKey, endpointUrl: endpointUrl.replace('/v0/', '') }).base(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 ${apiKey}`,
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<AxiosResponse> {
return await this._axiosClient.get('/meta/whoami')
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')
Expand Down
Loading
Loading