diff --git a/integrations/airtable/hub.md b/integrations/airtable/hub.md new file mode 100644 index 00000000000..bc51fa164b3 --- /dev/null +++ b/integrations/airtable/hub.md @@ -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. diff --git a/integrations/airtable/icon.svg b/integrations/airtable/icon.svg new file mode 100644 index 00000000000..3f9d51bd3be --- /dev/null +++ b/integrations/airtable/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/integrations/airtable/integration.definition.ts b/integrations/airtable/integration.definition.ts new file mode 100644 index 00000000000..1cc24d39c5e --- /dev/null +++ b/integrations/airtable/integration.definition.ts @@ -0,0 +1,94 @@ +import { IntegrationDefinition } from '@botpress/sdk' +import { z } from 'zod' +import { + createRecordInputSchema, + createRecordOutputSchema, + createTableInputSchema, + createTableOutputSchema, + getTableRecordsInputSchema, + getTableRecordsOutputSchema, + updateRecordInputSchema, + updateRecordOutputSchema, + updateTableInputSchema, + updateTableOutputSchema, +} from './src/misc/custom-schemas' +import { createRecordUi, createTableUi, getTableRecordsUi, updateRecordUi, updateTableUi } from './src/misc/custom-uis' + +const INTEGRATION_NAME = 'airtable' + +export default new IntegrationDefinition({ + name: INTEGRATION_NAME, + title: 'Airtable', + version: '0.2.0', + readme: 'readme.md', + icon: 'icon.svg', + configuration: { + schema: z.object({ + accessToken: z.string().describe('Personal Access Token'), + baseId: z.string().describe('Base ID'), + endpointUrl: z + .string() + .optional() + .default('https://api.airtable.com/v0/') + .describe('API endpoint to hit (Default: https://api.airtable.com/v0/)'), + }), + }, + channels: {}, + user: { + tags: {}, + }, + actions: { + getTableRecords: { + title: 'Get Records of the Table', + input: { + schema: getTableRecordsInputSchema, + ui: getTableRecordsUi, + }, + output: { + schema: getTableRecordsOutputSchema, + }, + }, + createTable: { + title: 'Create Table', + input: { + schema: createTableInputSchema, + ui: createTableUi, + }, + output: { + schema: createTableOutputSchema, + }, + }, + updateTable: { + title: 'Update Table', + input: { + schema: updateTableInputSchema, + ui: updateTableUi, + }, + output: { + schema: updateTableOutputSchema, + }, + }, + createRecord: { + title: 'Create Record', + input: { + schema: createRecordInputSchema, + ui: createRecordUi, + }, + output: { + schema: createRecordOutputSchema, + }, + }, + updateRecord: { + title: 'Update Record', + input: { + schema: updateRecordInputSchema, + ui: updateRecordUi, + }, + output: { + schema: updateRecordOutputSchema, + }, + }, + }, + events: {}, + states: {}, +}) diff --git a/integrations/airtable/package.json b/integrations/airtable/package.json new file mode 100644 index 00000000000..42492145a3c --- /dev/null +++ b/integrations/airtable/package.json @@ -0,0 +1,26 @@ +{ + "name": "@botpresshub/airtable", + "scripts": { + "start": "pnpm bp serve", + "build": "pnpm bp build", + "deploy": "pnpm bp deploy", + "type:check": "tsc --noEmit", + "test": "echo \"Tests not implemented yet.\"" + }, + "keywords": [], + "private": true, + "author": "", + "license": "MIT", + "dependencies": { + "@botpress/client": "workspace:*", + "@botpress/sdk": "workspace:*", + "airtable": "^0.12.2", + "axios": "^1.5.0", + "zod": "3.20.6" + }, + "devDependencies": { + "@types/node": "^18.11.17", + "ts-node": "^10.9.1", + "typescript": "^4.9.4" + } +} diff --git a/integrations/airtable/src/actions/create-record.ts b/integrations/airtable/src/actions/create-record.ts new file mode 100644 index 00000000000..613f601889b --- /dev/null +++ b/integrations/airtable/src/actions/create-record.ts @@ -0,0 +1,23 @@ +import { createRecordInputSchema } from '../misc/custom-schemas' +import type { IntegrationProps } from '../misc/types' +import { getClient } from '../utils' + +export const createRecord: IntegrationProps['actions']['createRecord'] = async ({ ctx, logger, input }) => { + const validatedInput = createRecordInputSchema.parse(input) + const AirtableClient = getClient(ctx.configuration) + + try { + const record = await AirtableClient.createRecord(validatedInput.tableIdOrName, JSON.parse(validatedInput.fields)) + logger.forBot().info(`Successful - Create Record - ${record.id}`) + return { + _rawJson: record.fields, + id: record.id, + } + } catch (error) { + logger.forBot().debug(`'Create Record' exception ${JSON.stringify(error)}`) + return { + _rawJson: {}, + id: '', + } + } +} diff --git a/integrations/airtable/src/actions/create-table.ts b/integrations/airtable/src/actions/create-table.ts new file mode 100644 index 00000000000..87973b10af4 --- /dev/null +++ b/integrations/airtable/src/actions/create-table.ts @@ -0,0 +1,21 @@ +import { createTableInputSchema } from '../misc/custom-schemas' +import type { IntegrationProps } from '../misc/types' +import { fieldsStringToArray, getClient } from '../utils' + +export const createTable: IntegrationProps['actions']['createTable'] = async ({ ctx, logger, input }) => { + const validatedInput = createTableInputSchema.parse(input) + const AirtableClient = getClient(ctx.configuration) + + try { + const table = await AirtableClient.createTable( + validatedInput.name, + fieldsStringToArray(validatedInput.fields), + validatedInput.description + ) + logger.forBot().info(`Successful - Create Table - ${table.id} - ${table.name}`) + return table + } catch (error) { + logger.forBot().debug(`'Create Table' exception ${JSON.stringify(error)}`) + return {} + } +} diff --git a/integrations/airtable/src/actions/get-table-records.ts b/integrations/airtable/src/actions/get-table-records.ts new file mode 100644 index 00000000000..0ea0042ec49 --- /dev/null +++ b/integrations/airtable/src/actions/get-table-records.ts @@ -0,0 +1,22 @@ +import { getTableRecordsInputSchema } from '../misc/custom-schemas' +import type { IntegrationProps } from '../misc/types' +import { getClient } from '../utils' + +export const getTableRecords: IntegrationProps['actions']['getTableRecords'] = async ({ ctx, logger, input }) => { + const validatedInput = getTableRecordsInputSchema.parse(input) + const AirtableClient = getClient(ctx.configuration) + try { + const output = await AirtableClient.getTableRecords(validatedInput.tableIdOrName) + const records = output.map((record) => { + return { + _rawJson: record.fields, + id: record.id, + } + }) + logger.forBot().info(`Successful - Get Table Records - ${validatedInput.tableIdOrName}`) + return { records } + } catch (error) { + logger.forBot().debug(`'Get Table Records' exception ${JSON.stringify(error)}`) + return { records: [] } + } +} diff --git a/integrations/airtable/src/actions/index.ts b/integrations/airtable/src/actions/index.ts new file mode 100644 index 00000000000..3ba9bc773a3 --- /dev/null +++ b/integrations/airtable/src/actions/index.ts @@ -0,0 +1,13 @@ +import { createRecord } from './create-record' +import { createTable } from './create-table' +import { getTableRecords } from './get-table-records' +import { updateRecord } from './update-record' +import { updateTable } from './update-table' + +export default { + getTableRecords, + createTable, + updateTable, + createRecord, + updateRecord, +} diff --git a/integrations/airtable/src/actions/update-record.ts b/integrations/airtable/src/actions/update-record.ts new file mode 100644 index 00000000000..c8791e4b73d --- /dev/null +++ b/integrations/airtable/src/actions/update-record.ts @@ -0,0 +1,28 @@ +import { updateRecordInputSchema } from '../misc/custom-schemas' +import type { IntegrationProps } from '../misc/types' +import { getClient } from '../utils' + +export const updateRecord: IntegrationProps['actions']['updateRecord'] = async ({ ctx, logger, input }) => { + const validatedInput = updateRecordInputSchema.parse(input) + const AirtableClient = getClient(ctx.configuration) + + try { + const output = await AirtableClient.updateRecord( + validatedInput.tableIdOrName, + validatedInput.recordId, + JSON.parse(validatedInput.fields) + ) + const record = { + _rawJson: output.fields, + id: output.id, + } + logger.forBot().info(`Successful - Update Record - ${record.id}`) + return record + } catch (error) { + logger.forBot().debug(`'Update Record' exception ${JSON.stringify(error)}`) + return { + _rawJson: {}, + id: '', + } + } +} diff --git a/integrations/airtable/src/actions/update-table.ts b/integrations/airtable/src/actions/update-table.ts new file mode 100644 index 00000000000..74fdc7e2ec8 --- /dev/null +++ b/integrations/airtable/src/actions/update-table.ts @@ -0,0 +1,21 @@ +import { updateTableInputSchema } from '../misc/custom-schemas' +import type { IntegrationProps } from '../misc/types' +import { getClient } from '../utils' + +export const updateTable: IntegrationProps['actions']['updateTable'] = async ({ ctx, logger, input }) => { + const validatedInput = updateTableInputSchema.parse(input) + const AirtableClient = getClient(ctx.configuration) + + try { + const table = await AirtableClient.updateTable( + validatedInput.tableIdOrName, + validatedInput.name, + validatedInput.description + ) + logger.forBot().info(`Successful - Update Table - ${table.id} - ${table.name}`) + return table + } catch (error) { + logger.forBot().debug(`'Update Table' exception ${JSON.stringify(error)}`) + return {} + } +} diff --git a/integrations/airtable/src/client.ts b/integrations/airtable/src/client.ts new file mode 100644 index 00000000000..36ce20d81d9 --- /dev/null +++ b/integrations/airtable/src/client.ts @@ -0,0 +1,57 @@ +import Airtable from 'airtable' +import axios, { AxiosInstance } from 'axios' +import { TableFields } from './misc/types' + +export class AirtableApi { + private base: Airtable.Base + private axiosClient: AxiosInstance + private baseId: string + + constructor(apiKey: string, baseId: string, endpointUrl?: string) { + this.baseId = baseId + this.base = new Airtable({ apiKey, endpointUrl }).base(baseId) + this.axiosClient = axios.create({ + baseURL: endpointUrl || 'https://api.airtable.com/v0/', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + }) + } + + async getTableRecords(tableIdOrName: string) { + const records = await this.base(tableIdOrName).select().all() + return records + } + + async createRecord(tableIdOrName: string, fields: object) { + const record = await this.base(tableIdOrName).create(fields) + return record + } + + async updateRecord(tableIdOrName: string, recordId: string, fields: object) { + const record = await this.base(tableIdOrName).update(recordId, fields) + return record + } + + async createTable(name: string, fields: TableFields, description?: string) { + 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 + } + + async updateTable(tableIdOrName: string, name?: string, description?: string) { + const response = await this.axiosClient.patch(`/meta/bases/${this.baseId}/tables/${tableIdOrName}`, { + name, + description, + }) + return response.data + } +} diff --git a/integrations/airtable/src/index.ts b/integrations/airtable/src/index.ts new file mode 100644 index 00000000000..d5690775675 --- /dev/null +++ b/integrations/airtable/src/index.ts @@ -0,0 +1,10 @@ +import actions from './actions' +import * as botpress from '.botpress' + +export default new botpress.Integration({ + register: async () => {}, + unregister: async () => {}, + actions, + channels: {}, + handler: async () => {}, +}) diff --git a/integrations/airtable/src/misc/custom-schemas.ts b/integrations/airtable/src/misc/custom-schemas.ts new file mode 100644 index 00000000000..f9436ae193f --- /dev/null +++ b/integrations/airtable/src/misc/custom-schemas.ts @@ -0,0 +1,55 @@ +import z from 'zod' +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)'), +}) + +export const getTableRecordsOutputSchema = z + .object({ + records: z.array(recordSchema), + }) + .passthrough() + +export const createTableInputSchema = z.object({ + name: z.string().describe('Name of the Table (e.g. MyTable)'), + fields: z + .string() + .describe( + 'The Table\'s fields, separated by commas. Each field should be in the format "type_name" (e.g. "phoneNumber_Customer Phone, singleLineText_Address").' + ), + description: z.string().optional().describe('Description of the Table (e.g. This is my table) (Optional)'), +}) + +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)'), + name: z.string().optional().describe('Name of the Table (e.g. MyTable) (Optional)'), + description: z.string().optional().describe('Description of the Table (e.g. This is my table) (Optional)'), +}) + +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)'), + fields: z + .string() + .describe( + 'The fields and their values for the new record, in a JSON format (e.g. {"Name":"John Doe","City":"In the moon","Verify":true})' + ), +}) + +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)'), + recordId: z.string().describe('The ID of the Record to be updated'), + fields: z + .string() + .describe( + 'The fields and their values for the record to be updated, in a JSON format (e.g. {"Name":"John Doe","Verify":true})' + ), +}) + +export const updateRecordOutputSchema = recordSchema.passthrough() diff --git a/integrations/airtable/src/misc/custom-uis.ts b/integrations/airtable/src/misc/custom-uis.ts new file mode 100644 index 00000000000..dad8bdc508b --- /dev/null +++ b/integrations/airtable/src/misc/custom-uis.ts @@ -0,0 +1,52 @@ +export const getBaseTablesUi = {} + +export const getTableRecordsUi = { + tableIdOrName: { + title: 'The ID or Name of the table (e.g. tblFnqcm4zLVKn85A or articles)', + }, +} + +export const createTableUi = { + name: { title: 'Name of the Table (e.g. MyTable)' }, + fields: { + title: + 'The Table\'s fields, separated by commas. Each field should be in the format "type_name" (e.g. "phoneNumber_Customer Phone, singleLineText_Address").', + }, + description: { + title: 'Description of the Table (e.g. This is my table) (Optional)', + }, +} + +export const updateTableUi = { + tableIdOrName: { + title: 'The ID or Name of the table (e.g. tblFnqcm4zLVKn85A or articles)', + }, + name: { + title: 'Name of the Table (e.g. MyTable) (Optional)', + }, + description: { + title: 'Description of the Table (e.g. This is my table) (Optional)', + }, +} + +export const createRecordUi = { + tableIdOrName: { + title: 'The ID or Name of the table (e.g. tblFnqcm4zLVKn85A or articles)', + }, + fields: { + title: + 'The fields and their values for the new record, in a JSON format (e.g. {"Name":"John Doe","City":"In the moon","Verify":true})', + }, +} + +export const updateRecordUi = { + tableIdOrName: { + title: 'The ID or Name of the table (e.g. tblFnqcm4zLVKn85A or articles)', + }, + recordId: { + title: 'The ID of the Record to be updated', + }, + fields: { + title: 'The fields and their values for the new record, in a JSON format (e.g. {"Name":"John Doe","Verify":true})', + }, +} diff --git a/integrations/airtable/src/misc/sub-schemas.ts b/integrations/airtable/src/misc/sub-schemas.ts new file mode 100644 index 00000000000..362474c9b39 --- /dev/null +++ b/integrations/airtable/src/misc/sub-schemas.ts @@ -0,0 +1,21 @@ +import z from 'zod' + +const fieldSchema = z.object({ name: z.string(), type: z.string() }).passthrough() + +const viewSchema = z.object({ id: z.string(), name: z.string(), type: z.string() }).passthrough() + +const tableSchema = z.object({ + description: z.string().optional(), + fields: z.array(fieldSchema), + id: z.string(), + name: z.string(), + primaryFieldId: z.string(), + views: z.array(viewSchema), +}) + +const recordSchema = z.object({ + _rawJson: z.object({}).passthrough(), + id: z.string(), +}) + +export { tableSchema, recordSchema } diff --git a/integrations/airtable/src/misc/types.ts b/integrations/airtable/src/misc/types.ts new file mode 100644 index 00000000000..cb5c05b44c5 --- /dev/null +++ b/integrations/airtable/src/misc/types.ts @@ -0,0 +1,12 @@ +import type { IntegrationContext } from '@botpress/sdk' +import type * as bp from '.botpress' + +export type Configuration = bp.configuration.Configuration +export type IntegrationProps = ConstructorParameters[0] +export type IntegrationCtx = IntegrationContext + +export type RegisterFunction = IntegrationProps['register'] +export type UnregisterFunction = IntegrationProps['unregister'] +export type Channels = IntegrationProps['channels'] +export type Handler = IntegrationProps['handler'] +export type TableFields = Array<{ name: string; type: string }> diff --git a/integrations/airtable/src/utils.ts b/integrations/airtable/src/utils.ts new file mode 100644 index 00000000000..a330b93b169 --- /dev/null +++ b/integrations/airtable/src/utils.ts @@ -0,0 +1,27 @@ +import { AirtableApi } from './client' +import { Configuration } from './misc/types' + +export function getClient(config: Configuration) { + return new AirtableApi(config.accessToken, config.baseId, config.endpointUrl) +} + +export function fieldsStringToArray(fieldsString: string) { + try { + return fieldsString.split(',').map((fieldString) => { + const [type, name] = fieldString.trim().split('_') + if (type === '' || type === undefined) { + throw new Error('Type is Required') + } + if (name === '' || name === undefined) { + throw new Error('Name is Required') + } + + return { + type, + name, + } + }) + } catch (error) { + return [] + } +} diff --git a/integrations/airtable/tsconfig.json b/integrations/airtable/tsconfig.json new file mode 100644 index 00000000000..8ddae2134a6 --- /dev/null +++ b/integrations/airtable/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "es2017", + "baseUrl": ".", + "outDir": "dist", + "checkJs": false + }, + "include": [".botpress/**/*", "src/**/*", "package.json"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aba4b6a411d..8bae6d7e743 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -169,6 +169,34 @@ importers: specifier: ^4.9.4 version: 4.9.5 + integrations/airtable: + dependencies: + '@botpress/client': + specifier: workspace:* + version: link:../../packages/client + '@botpress/sdk': + specifier: workspace:* + version: link:../../packages/sdk + airtable: + specifier: ^0.12.2 + version: 0.12.2 + axios: + specifier: ^1.5.0 + version: 1.6.7 + zod: + specifier: 3.20.6 + version: 3.20.6 + devDependencies: + '@types/node': + specifier: ^18.11.17 + version: 18.16.16 + ts-node: + specifier: ^10.9.1 + version: 10.9.1(@types/node@18.16.16)(typescript@4.9.5) + typescript: + specifier: ^4.9.4 + version: 4.9.5 + integrations/asana: dependencies: '@botpress/sdk': @@ -4230,6 +4258,10 @@ packages: event-target-shim: 5.0.1 dev: false + /abortcontroller-polyfill@1.7.5: + resolution: {integrity: sha512-JMJ5soJWP18htbbxJjG7bG6yuI6pRhgJ0scHHTfkUjf6wjP912xZWvM+A4sJK3gqd9E8fcPbDnOefbA9Th/FIQ==} + dev: false + /accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -4295,6 +4327,19 @@ packages: indent-string: 4.0.0 dev: false + /airtable@0.12.2: + resolution: {integrity: sha512-HS3VytUBTKj8A0vPl7DDr5p/w3IOGv6RXL0fv7eczOWAtj9Xe8ri4TAiZRXoOyo+Z/COADCj+oARFenbxhmkIg==} + engines: {node: '>=8.0.0'} + dependencies: + '@types/node': 10.17.60 + abort-controller: 3.0.0 + abortcontroller-polyfill: 1.7.5 + lodash: 4.17.21 + node-fetch: 2.6.11 + transitivePeerDependencies: + - encoding + dev: false + /ajv-draft-04@1.0.0(ajv@8.12.0): resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} peerDependencies: