From 7dde4191c7769392645f67ae9f674a5369a22a92 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Apr 2025 13:53:28 -0700 Subject: [PATCH] feat(tools): added airtable integration for oauth2 and tools (#214) * feat(airtable): add Airtable integration with OAuth support ref #102 * added scope descriptions for airtable oauth --------- Co-authored-by: rodrick-mpofu --- .../components/oauth-required-modal.tsx | 2 + sim/blocks/blocks/airtable.test.ts | 89 +++++++++ sim/blocks/blocks/airtable.ts | 174 ++++++++++++++++++ sim/blocks/index.ts | 3 + sim/components/icons.tsx | 36 ++-- sim/lib/auth.ts | 49 +++++ sim/lib/oauth.ts | 23 ++- sim/tools/airtable/index.test.ts | 174 ++++++++++++++++++ sim/tools/airtable/index.ts | 7 + sim/tools/airtable/read.ts | 79 ++++++++ sim/tools/airtable/types.ts | 69 +++++++ sim/tools/airtable/update.ts | 68 +++++++ sim/tools/airtable/write.ts | 65 +++++++ sim/tools/index.ts | 4 + 14 files changed, 816 insertions(+), 26 deletions(-) create mode 100644 sim/blocks/blocks/airtable.test.ts create mode 100644 sim/blocks/blocks/airtable.ts create mode 100644 sim/tools/airtable/index.test.ts create mode 100644 sim/tools/airtable/index.ts create mode 100644 sim/tools/airtable/read.ts create mode 100644 sim/tools/airtable/types.ts create mode 100644 sim/tools/airtable/update.ts create mode 100644 sim/tools/airtable/write.ts diff --git a/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx index 1cc28e6884..2bd1bd03ef 100644 --- a/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx +++ b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx @@ -59,6 +59,8 @@ const SCOPE_DESCRIPTIONS: Record = { 'tweet.write': 'Post tweets on your behalf', 'users.read': 'Read your profile information', 'offline.access': 'Access your account when you are not using the application', + 'data.records:read': 'Read your records', + 'data.records:write': 'Write to your records', } // Convert OAuth scope to user-friendly description diff --git a/sim/blocks/blocks/airtable.test.ts b/sim/blocks/blocks/airtable.test.ts new file mode 100644 index 0000000000..41869aad43 --- /dev/null +++ b/sim/blocks/blocks/airtable.test.ts @@ -0,0 +1,89 @@ +/** + * @vitest-environment jsdom + * + * Airtable Block Integration Tests + */ +import { describe, expect, test } from 'vitest' +import { AirtableBlock } from './airtable' + +describe('Airtable Block', () => { + test('should have correct configuration', () => { + expect(AirtableBlock.type).toBe('airtable') + expect(AirtableBlock.category).toBe('tools') + expect(AirtableBlock.tools.access).toContain('airtable_read') + expect(AirtableBlock.tools.access).toContain('airtable_write') + expect(AirtableBlock.tools.access).toContain('airtable_update') + }) + + test('should select correct tool based on operation', () => { + const config = AirtableBlock.tools.config + + if (!config) { + throw new Error('Tool config is missing') + } + + expect(config.tool({ operation: 'read' })).toBe('airtable_read') + expect(config.tool({ operation: 'write' })).toBe('airtable_write') + expect(config.tool({ operation: 'update' })).toBe('airtable_update') + expect(() => config.tool({ operation: 'invalid' })).toThrow('Invalid Airtable operation') + }) + + test('should transform parameters correctly', () => { + const config = AirtableBlock.tools.config + + if (!config || !config.params) { + throw new Error('Tool config or params is missing') + } + + // Test read operation + const readParams = config.params({ + operation: 'read', + credential: 'oauth_token', + baseId: 'base123', + tableId: 'table456', + maxRecords: '100', + filterFormula: "Status='Active'" + }) + + expect(readParams.accessToken).toBe('oauth_token') + expect(readParams.baseId).toBe('base123') + expect(readParams.tableId).toBe('table456') + + // Test write operation + const writeParams = config.params({ + operation: 'write', + credential: 'oauth_token', + baseId: 'base123', + tableId: 'table456', + records: JSON.stringify([{ fields: { Name: 'Test' } }]) + }) + + expect(writeParams.accessToken).toBe('oauth_token') + expect(writeParams.records).toEqual([{ fields: { Name: 'Test' } }]) + + // Test update operation + const updateParams = config.params({ + operation: 'update', + credential: 'oauth_token', + baseId: 'base123', + tableId: 'table456', + recordId: 'rec789', + records: JSON.stringify({ fields: { Name: 'Updated' } }) + }) + + expect(updateParams.accessToken).toBe('oauth_token') + expect(updateParams.recordId).toBe('rec789') + expect(updateParams.fields).toEqual({ fields: { Name: 'Updated' } }) + }) + + test('should have required OAuth configuration', () => { + const subBlocks = AirtableBlock.subBlocks + const credentialBlock = subBlocks.find(block => block.id === 'credential') + + expect(credentialBlock).toBeDefined() + expect(credentialBlock?.type).toBe('oauth-input') + expect(credentialBlock?.provider).toBe('airtable') + expect(credentialBlock?.requiredScopes).toContain('data.records:read') + expect(credentialBlock?.requiredScopes).toContain('data.records:write') + }) +}) \ No newline at end of file diff --git a/sim/blocks/blocks/airtable.ts b/sim/blocks/blocks/airtable.ts new file mode 100644 index 0000000000..17da52321a --- /dev/null +++ b/sim/blocks/blocks/airtable.ts @@ -0,0 +1,174 @@ +import { AirtableIcon } from '@/components/icons' +import { + AirtableReadResponse, + AirtableWriteResponse, + AirtableUpdateResponse, +} from '@/tools/airtable/types' +import { BlockConfig } from '../types' + +type AirtableResponse = AirtableReadResponse | AirtableWriteResponse | AirtableUpdateResponse + +export const AirtableBlock: BlockConfig = { + type: 'airtable', + name: 'Airtable', + description: 'Read, write, and update Airtable records', + longDescription: + 'Integrate Airtable functionality to manage table records. Read data from existing tables, ' + + 'write new records, and update existing ones using OAuth authentication. Supports table ' + + 'selection and record operations with custom field mapping.', + category: 'tools', + bgColor: '#FCB400', + icon: AirtableIcon, + subBlocks: [ + // Operation selector + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + layout: 'full', + options: [ + { label: 'Read Records', id: 'read' }, + { label: 'Write Records', id: 'write' }, + { label: 'Update Records', id: 'update' }, + ], + }, + // Airtable Credentials + { + id: 'credential', + title: 'Airtable Account', + type: 'oauth-input', + layout: 'full', + provider: 'airtable', + serviceId: 'airtable', + requiredScopes: ['data.records:read', 'data.records:write'], + placeholder: 'Select Airtable account', + }, + // Base ID + { + id: 'baseId', + title: 'Base ID', + type: 'short-input', + layout: 'full', + placeholder: 'Enter your base ID (found in the API documentation)', + }, + // Table Name/ID + { + id: 'tableId', + title: 'Table Name/ID', + type: 'short-input', + layout: 'full', + placeholder: 'Enter table name or ID', + }, + // Read Operation Fields + { + id: 'maxRecords', + title: 'Max Records', + type: 'short-input', + layout: 'half', + placeholder: 'Maximum number of records to return', + condition: { field: 'operation', value: 'read' }, + }, + { + id: 'filterFormula', + title: 'Filter Formula', + type: 'long-input', + layout: 'full', + placeholder: 'Enter Airtable formula to filter records', + condition: { field: 'operation', value: 'read' }, + }, + // Write Operation Fields + { + id: 'records', + title: 'Records', + type: 'code', + layout: 'full', + placeholder: 'Enter records in JSON format', + condition: { field: 'operation', value: 'write' }, + }, + // Update Operation Fields + { + id: 'records', + title: 'Record Fields', + type: 'code', + layout: 'full', + placeholder: 'Enter record fields in JSON format', + condition: { field: 'operation', value: 'update' }, + }, + { + id: 'recordId', + title: 'Record ID', + type: 'short-input', + layout: 'full', + placeholder: 'ID of the record to update', + condition: { field: 'operation', value: 'update' }, + }, + ], + tools: { + access: ['airtable_read', 'airtable_write', 'airtable_update'], + config: { + tool: (params) => { + switch (params.operation) { + case 'read': + return 'airtable_read' + case 'write': + return 'airtable_write' + case 'update': + return 'airtable_update' + default: + throw new Error(`Invalid Airtable operation: ${params.operation}`) + } + }, + params: (params) => { + const { credential, records, ...rest } = params + + if (params.operation === 'update' && records) { + const parsedRecords = JSON.parse(records) + return { + accessToken: credential, + fields: parsedRecords, + ...rest, + } + } + + return { + accessToken: credential, + records: records ? JSON.parse(records) : undefined, + ...rest, + } + }, + }, + }, + inputs: { + operation: { type: 'string', required: true }, + credential: { type: 'string', required: true }, + baseId: { type: 'string', required: true }, + tableId: { type: 'string', required: true }, + // Read operation inputs + maxRecords: { type: 'number', required: false }, + filterFormula: { type: 'string', required: false }, + // Write/Update operation inputs + records: { + type: 'json', + required: false, + schema: { + type: 'object', + properties: { + fields: { + type: 'object', + additionalProperties: true, + }, + }, + required: ['fields'], + }, + }, + recordId: { type: 'string', required: false }, + }, + outputs: { + response: { + type: { + records: 'json', + metadata: 'json', + }, + }, + }, +} diff --git a/sim/blocks/index.ts b/sim/blocks/index.ts index ef4d12cfee..dfb9920690 100644 --- a/sim/blocks/index.ts +++ b/sim/blocks/index.ts @@ -33,11 +33,13 @@ import { VisionBlock } from './blocks/vision' import { WhatsAppBlock } from './blocks/whatsapp' import { XBlock } from './blocks/x' import { YouTubeBlock } from './blocks/youtube' +import { AirtableBlock } from './blocks/airtable' import { BlockConfig } from './types' // Export blocks for ease of use export { AgentBlock, + AirtableBlock, ApiBlock, FileBlock, FunctionBlock, @@ -76,6 +78,7 @@ export { // Registry of all block configurations, alphabetically sorted const blocks: Record = { agent: AgentBlock, + airtable: AirtableBlock, api: ApiBlock, condition: ConditionBlock, confluence: ConfluenceBlock, diff --git a/sim/components/icons.tsx b/sim/components/icons.tsx index 598086f5aa..51a3fb145b 100644 --- a/sim/components/icons.tsx +++ b/sim/components/icons.tsx @@ -1516,33 +1516,19 @@ export function RedditIcon(props: SVGProps) { export function AirtableIcon(props: SVGProps) { return ( - - - - - - + + + + + ) } diff --git a/sim/lib/auth.ts b/sim/lib/auth.ts index 581d4070e1..cbd3eefc01 100644 --- a/sim/lib/auth.ts +++ b/sim/lib/auth.ts @@ -412,6 +412,55 @@ export const auth = betterAuth({ } }, }, + + // Airtable provider + { + providerId: 'airtable', + clientId: process.env.AIRTABLE_CLIENT_ID as string, + clientSecret: process.env.AIRTABLE_CLIENT_SECRET as string, + authorizationUrl: 'https://airtable.com/oauth/authorize', + tokenUrl: 'https://airtable.com/oauth/token', + userInfoUrl: 'https://api.airtable.com/v0/meta/whoami', + scopes: ['data.records:read', 'data.records:write'], + responseType: 'code', + pkce: true, + accessType: 'offline', + prompt: 'consent', + redirectURI: `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/airtable`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://api.airtable.com/v0/meta/whoami', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + logger.error('Error fetching Airtable user info:', { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const profile = await response.json() + const now = new Date() + + return { + id: profile.id, + name: profile.name || 'Airtable User', + email: profile.email, + image: null, + emailVerified: true, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Airtable getUserInfo:', { error }) + return null + } + }, + }, ], }), ], diff --git a/sim/lib/oauth.ts b/sim/lib/oauth.ts index d2c1e1c0ff..292b3bc707 100644 --- a/sim/lib/oauth.ts +++ b/sim/lib/oauth.ts @@ -10,13 +10,14 @@ import { GoogleSheetsIcon, SupabaseIcon, xIcon, + AirtableIcon, } from '@/components/icons' import { createLogger } from '@/lib/logs/console-logger' const logger = createLogger('OAuth') // Define the base OAuth provider type -export type OAuthProvider = 'google' | 'github' | 'x' | 'supabase' | 'confluence' | string +export type OAuthProvider = 'google' | 'github' | 'x' | 'supabase' | 'confluence' | 'airtable' | string export type OAuthService = | 'google' | 'google-email' @@ -27,6 +28,7 @@ export type OAuthService = | 'x' | 'supabase' | 'confluence' + | 'airtable' // Define the interface for OAuth provider configuration export interface OAuthProviderConfig { @@ -193,6 +195,23 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'confluence', }, + airtable: { + id: 'airtable', + name: 'Airtable', + icon: (props) => AirtableIcon(props), + services: { + airtable: { + id: 'airtable', + name: 'Airtable', + description: 'Manage Airtable bases, tables, and records.', + providerId: 'airtable', + icon: (props) => AirtableIcon(props), + baseProviderIcon: (props) => AirtableIcon(props), + scopes: ['data.records:read', 'data.records:write'], + }, + }, + defaultService: 'airtable', + }, } // Helper function to get a service by provider and service ID @@ -243,6 +262,8 @@ export function getServiceIdFromScopes(provider: OAuthProvider, scopes: string[] return 'x' } else if (provider === 'confluence') { return 'confluence' + } else if (provider === 'airtable') { + return 'airtable' } return providerConfig.defaultService diff --git a/sim/tools/airtable/index.test.ts b/sim/tools/airtable/index.test.ts new file mode 100644 index 0000000000..677d75ac1e --- /dev/null +++ b/sim/tools/airtable/index.test.ts @@ -0,0 +1,174 @@ +/** + * @vitest-environment jsdom + * + * Airtable Tools Integration Tests + */ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { ToolTester } from '../__test-utils__/test-tools' +import { airtableReadTool, airtableWriteTool, airtableUpdateTool } from './index' + +describe('Airtable Tools Integration', () => { + let tester: ToolTester + + beforeEach(() => { + process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000' + }) + + afterEach(() => { + vi.resetAllMocks() + delete process.env.NEXT_PUBLIC_APP_URL + }) + + describe('Airtable Read Tool', () => { + beforeEach(() => { + tester = new ToolTester(airtableReadTool) + }) + + test('should construct correct read request', () => { + const params = { + baseId: 'base123', + tableId: 'table456', + accessToken: 'token789', + maxRecords: 100, + filterFormula: "Status='Active'" + } + + const url = tester.getRequestUrl(params) + const headers = tester.getRequestHeaders(params) + + expect(url).toContain('/base123/table456') + expect(url).toContain('maxRecords=100') + expect(url).toContain('filterByFormula=Status%3D%27Active%27') + expect(headers['Authorization']).toBe('Bearer token789') + }) + + test('should handle successful read response', async () => { + const mockData = { + records: [ + { id: 'rec1', fields: { Name: 'Test 1' } }, + { id: 'rec2', fields: { Name: 'Test 2' } } + ], + offset: 'next_page_token' + } + + tester.setup(mockData) + + const result = await tester.execute({ + baseId: 'base123', + tableId: 'table456', + accessToken: 'token789' + }) + + expect(result.success).toBe(true) + expect(result.output.records).toHaveLength(2) + expect(result.output.metadata.offset).toBe('next_page_token') + expect(result.output.metadata.totalRecords).toBe(2) + }) + }) + + describe('Airtable Write Tool', () => { + beforeEach(() => { + tester = new ToolTester(airtableWriteTool) + }) + + test('should construct correct write request', () => { + const params = { + baseId: 'base123', + tableId: 'table456', + accessToken: 'token789', + records: [{ fields: { Name: 'New Record' } }] + } + + const url = tester.getRequestUrl(params) + const headers = tester.getRequestHeaders(params) + const body = tester.getRequestBody(params) + + expect(url).toContain('/base123/table456') + expect(headers['Authorization']).toBe('Bearer token789') + expect(body).toEqual({ records: [{ fields: { Name: 'New Record' } }] }) + }) + + test('should handle successful write response', async () => { + const mockData = { + records: [ + { id: 'rec1', fields: { Name: 'New Record' } } + ] + } + + tester.setup(mockData) + + const result = await tester.execute({ + baseId: 'base123', + tableId: 'table456', + accessToken: 'token789', + records: [{ fields: { Name: 'New Record' } }] + }) + + expect(result.success).toBe(true) + expect(result.output.records).toHaveLength(1) + expect(result.output.metadata.recordCount).toBe(1) + }) + }) + + describe('Airtable Update Tool', () => { + beforeEach(() => { + tester = new ToolTester(airtableUpdateTool) + }) + + test('should construct correct update request', () => { + const params = { + baseId: 'base123', + tableId: 'table456', + recordId: 'rec789', + accessToken: 'token789', + fields: { Name: 'Updated Record' } + } + + const url = tester.getRequestUrl(params) + const headers = tester.getRequestHeaders(params) + const body = tester.getRequestBody(params) + + expect(url).toContain('/base123/table456/rec789') + expect(headers['Authorization']).toBe('Bearer token789') + expect(body).toEqual({ fields: { Name: 'Updated Record' } }) + }) + + test('should handle successful update response', async () => { + const mockData = { + id: 'rec789', + fields: { Name: 'Updated Record' } + } + + tester.setup(mockData) + + const result = await tester.execute({ + baseId: 'base123', + tableId: 'table456', + recordId: 'rec789', + accessToken: 'token789', + fields: { Name: 'Updated Record' } + }) + + expect(result.success).toBe(true) + expect(result.output.records).toHaveLength(1) + expect(result.output.metadata.recordCount).toBe(1) + expect(result.output.metadata.updatedFields).toContain('Name') + }) + }) + + test('should handle error responses', async () => { + tester = new ToolTester(airtableReadTool) + + const errorMessage = 'Invalid API key' + tester.setup({ error: errorMessage }, { ok: false, status: 401 }) + + const result = await tester.execute({ + baseId: 'base123', + tableId: 'table456', + accessToken: 'invalid_token' + }) + + expect(result.success).toBe(false) + expect(result.error).toContain('Failed to read Airtable records') + }) +}) \ No newline at end of file diff --git a/sim/tools/airtable/index.ts b/sim/tools/airtable/index.ts new file mode 100644 index 0000000000..23ec73b475 --- /dev/null +++ b/sim/tools/airtable/index.ts @@ -0,0 +1,7 @@ +import { readTool } from './read' +import { writeTool } from './write' +import { updateTool } from './update' + +export const airtableReadTool = readTool +export const airtableWriteTool = writeTool +export const airtableUpdateTool = updateTool \ No newline at end of file diff --git a/sim/tools/airtable/read.ts b/sim/tools/airtable/read.ts new file mode 100644 index 0000000000..169af835b3 --- /dev/null +++ b/sim/tools/airtable/read.ts @@ -0,0 +1,79 @@ +import { ToolConfig } from '../types' +import { AirtableReadParams, AirtableReadResponse } from './types' + +export const readTool: ToolConfig = { + id: 'airtable_read', + name: 'Airtable Read Records', + description: 'Read records from an Airtable table', + version: '1.0.0', + + oauth: { + required: true, + provider: 'airtable', + }, + + params: { + accessToken: { + type: 'string', + required: true, + description: 'OAuth access token', + }, + baseId: { + type: 'string', + required: true, + description: 'ID of the Airtable base', + }, + tableId: { + type: 'string', + required: true, + description: 'ID or name of the table', + }, + maxRecords: { + type: 'number', + required: false, + description: 'Maximum number of records to return', + }, + filterFormula: { + type: 'string', + required: false, + description: 'Formula to filter records', + }, + }, + + request: { + url: (params) => { + const url = `https://api.airtable.com/v0/${params.baseId}/${params.tableId}` + const queryParams = new URLSearchParams() + if (params.maxRecords) queryParams.append('maxRecords', params.maxRecords.toString()) + if (params.filterFormula) { + const encodedFormula = encodeURIComponent(params.filterFormula).replace(/'/g, '%27') + queryParams.append('filterByFormula', encodedFormula) + } + const queryString = queryParams.toString() + return queryString ? `${url}?${queryString}` : url + }, + method: 'GET', + headers: (params) => ({ + 'Authorization': `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + return { + success: true, + output: { + records: data.records, + metadata: { + offset: data.offset, + totalRecords: data.records.length, + }, + }, + } + }, + + transformError: (error) => { + return `Failed to read Airtable records: ${error.message}` + }, +} \ No newline at end of file diff --git a/sim/tools/airtable/types.ts b/sim/tools/airtable/types.ts new file mode 100644 index 0000000000..eb864de67f --- /dev/null +++ b/sim/tools/airtable/types.ts @@ -0,0 +1,69 @@ +import { ToolResponse } from '../types' + +// Common interfaces +interface AirtableRecord { + id: string + createdTime: string + fields: Record +} + +interface AirtableError { + type: string + message: string +} + +// Response interfaces +export interface AirtableReadResponse extends ToolResponse { + output: { + records: AirtableRecord[] + metadata: { + offset?: string + totalRecords?: number + } + } +} + +export interface AirtableWriteResponse extends ToolResponse { + output: { + records: AirtableRecord[] + metadata: { + recordCount: number + } + } +} + +export interface AirtableUpdateResponse extends ToolResponse { + output: { + records: AirtableRecord[] + metadata: { + recordCount: number + updatedFields: string[] + } + } +} + +// Request interfaces +export interface AirtableReadParams { + accessToken: string + baseId: string + tableId: string + maxRecords?: number + filterFormula?: string +} + +export interface AirtableWriteParams { + accessToken: string + baseId: string + tableId: string + records: Array<{ + fields: Record + }> +} + +export interface AirtableUpdateParams { + accessToken: string + baseId: string + tableId: string + recordId: string + fields: Record +} \ No newline at end of file diff --git a/sim/tools/airtable/update.ts b/sim/tools/airtable/update.ts new file mode 100644 index 0000000000..818b38acb2 --- /dev/null +++ b/sim/tools/airtable/update.ts @@ -0,0 +1,68 @@ +import { ToolConfig } from '../types' +import { AirtableUpdateParams, AirtableUpdateResponse } from './types' + +export const updateTool: ToolConfig = { + id: 'airtable_update', + name: 'Airtable Update Records', + description: 'Update existing records in an Airtable table', + version: '1.0.0', + + provider: 'airtable', + + params: { + accessToken: { + type: 'string', + required: true, + description: 'OAuth access token', + }, + baseId: { + type: 'string', + required: true, + description: 'ID of the Airtable base', + }, + tableId: { + type: 'string', + required: true, + description: 'ID or name of the table', + }, + recordId: { + type: 'string', + required: true, + description: 'ID of the record to update', + }, + fields: { + type: 'json', + required: true, + description: 'Fields to update', + }, + }, + + request: { + url: (params) => + `https://api.airtable.com/v0/${params.baseId}/${params.tableId}/${params.recordId}`, + method: 'PATCH', + headers: (params) => ({ + 'Authorization': `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + body: (params) => ({ fields: params.fields }), + }, + + transformResponse: async (response) => { + const data = await response.json() + return { + success: true, + output: { + records: [data], + metadata: { + recordCount: 1, + updatedFields: Object.keys(data.fields), + }, + }, + } + }, + + transformError: (error) => { + return `Failed to update Airtable record: ${error.message}` + }, +} \ No newline at end of file diff --git a/sim/tools/airtable/write.ts b/sim/tools/airtable/write.ts new file mode 100644 index 0000000000..8680994168 --- /dev/null +++ b/sim/tools/airtable/write.ts @@ -0,0 +1,65 @@ +import { ToolConfig } from '../types' +import { AirtableWriteParams, AirtableWriteResponse } from './types' + +export const writeTool: ToolConfig = { + id: 'airtable_write', + name: 'Airtable Write Records', + description: 'Write new records to an Airtable table', + version: '1.0.0', + + oauth: { + required: true, + provider: 'airtable', + }, + + params: { + accessToken: { + type: 'string', + required: true, + description: 'OAuth access token', + }, + baseId: { + type: 'string', + required: true, + description: 'ID of the Airtable base', + }, + tableId: { + type: 'string', + required: true, + description: 'ID or name of the table', + }, + records: { + type: 'json', + required: true, + description: 'Array of records to create', + }, + }, + + request: { + url: (params) => + `https://api.airtable.com/v0/${params.baseId}/${params.tableId}`, + method: 'POST', + headers: (params) => ({ + 'Authorization': `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + body: (params) => ({ records: params.records }), + }, + + transformResponse: async (response) => { + const data = await response.json() + return { + success: true, + output: { + records: data.records, + metadata: { + recordCount: data.records.length, + }, + }, + } + }, + + transformError: (error) => { + return `Failed to write Airtable records: ${error.message}` + }, +} \ No newline at end of file diff --git a/sim/tools/index.ts b/sim/tools/index.ts index 7591ca63f1..04679c5d8c 100644 --- a/sim/tools/index.ts +++ b/sim/tools/index.ts @@ -44,6 +44,7 @@ import { visionTool } from './vision/vision' import { whatsappSendMessageTool } from './whatsapp' import { xReadTool, xSearchTool, xUserTool, xWriteTool } from './x' import { youtubeSearchTool } from './youtube/search' +import { airtableReadTool, airtableWriteTool, airtableUpdateTool } from './airtable' const logger = createLogger('Tools') @@ -110,6 +111,9 @@ export const tools: Record = { confluence_list: confluenceListTool, confluence_update: confluenceUpdateTool, dalle_generate: dalleTool, + airtable_read: airtableReadTool, + airtable_write: airtableWriteTool, + airtable_update: airtableUpdateTool, } // Get a tool by its ID