chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -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.
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
webhookId = to_string!(.webhookId)
|
||||
webhookUrl = to_string!(.webhookUrl)
|
||||
|
||||
"{{ webhookUrl }}/oauth/wizard/start?state={{ webhookId }}"
|
||||
@@ -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)
|
||||
@@ -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 }
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist",
|
||||
"experimentalDecorators": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact"
|
||||
},
|
||||
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import config from '../../vitest.config'
|
||||
export default config
|
||||
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
# Anthropic Integration
|
||||
|
||||
This integration allows your bot to choose from a curated list of Claude models from [Anthropic](https://docs.anthropic.com/en/docs/about-claude/models) as the LLM of your choice for a node, workflow, or skill in your bot.
|
||||
|
||||
Usage is charged to the AI Spend of your workspace in Botpress Cloud at the [same pricing](https://www.anthropic.com/pricing) (at cost) as directly with Anthropic.
|
||||
@@ -0,0 +1,16 @@
|
||||
<svg version="1.1" id="Layer_1" xmlns:x="ns_extend;" xmlns:i="ns_ai;" xmlns:graph="ns_graphs;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 92.2 65" style="enable-background:new 0 0 92.2 65;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#181818;}
|
||||
</style>
|
||||
<metadata>
|
||||
<sfw xmlns="ns_sfw;">
|
||||
<slices>
|
||||
</slices>
|
||||
<sliceSourceBounds bottomLeftOrigin="true" height="65" width="92.2" x="-43.7" y="-98">
|
||||
</sliceSourceBounds>
|
||||
</sfw>
|
||||
</metadata>
|
||||
<path class="st0" d="M66.5,0H52.4l25.7,65h14.1L66.5,0z M25.7,0L0,65h14.4l5.3-13.6h26.9L51.8,65h14.4L40.5,0C40.5,0,25.7,0,25.7,0z
|
||||
M24.3,39.3l8.8-22.8l8.8,22.8H24.3z">
|
||||
</path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 714 B |
@@ -0,0 +1,30 @@
|
||||
import { z, IntegrationDefinition } from '@botpress/sdk'
|
||||
import { ModelId } from 'src/schemas'
|
||||
import llm from './bp_modules/llm'
|
||||
|
||||
export default new IntegrationDefinition({
|
||||
name: 'anthropic',
|
||||
title: 'Anthropic',
|
||||
description: 'Access a curated list of Claude models to set as your chosen LLM.',
|
||||
version: '18.0.1',
|
||||
readme: 'hub.md',
|
||||
icon: 'icon.svg',
|
||||
entities: {
|
||||
modelRef: {
|
||||
schema: z.object({
|
||||
id: ModelId,
|
||||
}),
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
ANTHROPIC_API_KEY: {
|
||||
description: 'Anthropic API key',
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
category: 'AI Models',
|
||||
repo: 'botpress',
|
||||
},
|
||||
}).extend(llm, ({ entities }) => ({
|
||||
entities: { modelRef: entities.modelRef },
|
||||
}))
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@botpresshub/anthropic",
|
||||
"scripts": {
|
||||
"build": "bp add -y && bp build",
|
||||
"check:type": "tsc --noEmit",
|
||||
"check:bplint": "bp lint",
|
||||
"test": "vitest --run"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.52.0",
|
||||
"@botpress/client": "workspace:*",
|
||||
"@botpress/common": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"@botpresshub/llm": "workspace:*"
|
||||
},
|
||||
"bpDependencies": {
|
||||
"llm": "../../interfaces/llm"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import { ToolChoiceAny, ToolChoiceTool, ToolChoiceAuto } from '@anthropic-ai/sdk/resources'
|
||||
import { MessageCreateParamsNonStreaming } from '@anthropic-ai/sdk/resources/messages'
|
||||
import { InvalidPayloadError } from '@botpress/client'
|
||||
import { llm } from '@botpress/common'
|
||||
import { z, IntegrationLogger } from '@botpress/sdk'
|
||||
import assert from 'assert'
|
||||
import { ReasoningModelIdReplacements, ThinkingModeBudgetTokens } from 'src'
|
||||
import { ModelId } from 'src/schemas'
|
||||
|
||||
// Reference: https://docs.anthropic.com/en/api/errors
|
||||
const AnthropicInnerErrorSchema = z.object({
|
||||
type: z.literal('error'),
|
||||
error: z.object({ type: z.string(), message: z.string() }),
|
||||
})
|
||||
|
||||
export async function generateContent(
|
||||
input: llm.GenerateContentInput,
|
||||
anthropic: Anthropic,
|
||||
logger: IntegrationLogger,
|
||||
params: {
|
||||
models: Record<ModelId, llm.ModelDetails>
|
||||
defaultModel: ModelId
|
||||
}
|
||||
): Promise<llm.GenerateContentOutput> {
|
||||
let modelId = (input.model?.id || params.defaultModel) as ModelId
|
||||
|
||||
if (modelId in ReasoningModelIdReplacements) {
|
||||
const replacementModelId = ReasoningModelIdReplacements[modelId]!
|
||||
|
||||
if (input.reasoningEffort === undefined) {
|
||||
input.reasoningEffort = 'medium'
|
||||
}
|
||||
|
||||
// TODO: Uncomment this when we have removed the fake "reasoning" model IDs from the model list.
|
||||
// logger
|
||||
// .forBot()
|
||||
// .warn(
|
||||
// `The model "${modelId}" has been deprecated, using "${replacementModelId}" instead with a "${input.reasoningEffort}" reasoning effort`
|
||||
// )
|
||||
|
||||
modelId = replacementModelId
|
||||
input.model = { id: modelId }
|
||||
}
|
||||
|
||||
const model = params.models[modelId]
|
||||
|
||||
if (!model) {
|
||||
throw new InvalidPayloadError(
|
||||
`Model ID "${modelId}" is not allowed, supported model IDs are: ${Object.keys(params.models).join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
if (input.messages.length === 0 && !input.systemPrompt) {
|
||||
throw new InvalidPayloadError('At least one message or a system prompt is required')
|
||||
}
|
||||
|
||||
if (input.maxTokens && input.maxTokens > model.output.maxTokens) {
|
||||
throw new InvalidPayloadError(
|
||||
`maxTokens must be less than or equal to ${model.output.maxTokens} for model ID "${modelId}`
|
||||
)
|
||||
}
|
||||
|
||||
if (input.responseFormat === 'json_object') {
|
||||
input.systemPrompt =
|
||||
(input.systemPrompt || '') +
|
||||
'\n\nYour response must always be in valid JSON format and expressed as a JSON object.'
|
||||
}
|
||||
|
||||
if (input.messages.length === 0) {
|
||||
// Anthropic requires at least one message, so we add one by default if none were provided.
|
||||
input.messages.push({
|
||||
type: 'text',
|
||||
role: 'user',
|
||||
content: 'Follow the instructions provided in the system prompt.',
|
||||
})
|
||||
}
|
||||
|
||||
const messages: Anthropic.MessageParam[] = []
|
||||
|
||||
for (const message of input.messages) {
|
||||
messages.push(await mapToAnthropicMessage(message))
|
||||
}
|
||||
|
||||
let response: Anthropic.Messages.Message | undefined
|
||||
|
||||
const request: MessageCreateParamsNonStreaming = {
|
||||
model: modelId,
|
||||
max_tokens: input.maxTokens ?? model.output.maxTokens,
|
||||
temperature: input.temperature,
|
||||
top_p: input.topP,
|
||||
system: input.systemPrompt,
|
||||
stop_sequences: input.stopSequences,
|
||||
metadata: {
|
||||
user_id: input.userId,
|
||||
},
|
||||
tools: mapToAnthropicTools(input),
|
||||
tool_choice: mapToAnthropicToolChoice(input.toolChoice),
|
||||
messages,
|
||||
}
|
||||
|
||||
// TODO: Remove this check once all 3.x models are removed,
|
||||
// see https://platform.claude.com/docs/en/about-claude/models/migration-guide#breaking-changes
|
||||
if (modelId === 'claude-opus-4-7') {
|
||||
// This is a stricter check for Opuse 4.7 as it does not support sampling parameters
|
||||
// see https://platform.claude.com/docs/en/about-claude/models/migration-guide#additional-breaking-changes
|
||||
request.top_k = undefined
|
||||
request.top_p = undefined
|
||||
request.temperature = undefined
|
||||
} else if (
|
||||
(modelId === 'claude-sonnet-4-5-20250929' ||
|
||||
modelId === 'claude-haiku-4-5-20251001' ||
|
||||
modelId === 'claude-sonnet-4-6' ||
|
||||
modelId === 'claude-opus-4-6') &&
|
||||
request.temperature !== undefined &&
|
||||
request.top_p !== undefined
|
||||
) {
|
||||
request.temperature = undefined
|
||||
}
|
||||
|
||||
const thinkingBudgetTokens = ThinkingModeBudgetTokens[input.reasoningEffort ?? 'none'] // Default to not use reasoning as Claude models use optional reasoning
|
||||
|
||||
if (thinkingBudgetTokens) {
|
||||
// Claude requires a non-zero thinking budget when thinking mode is enabled.
|
||||
request.thinking = {
|
||||
// Reference: https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking
|
||||
type: 'enabled',
|
||||
budget_tokens: thinkingBudgetTokens,
|
||||
}
|
||||
|
||||
// IMPORTANT: Thinking mode requires the max tokens to be greater than the thinking budget tokens, and we assume here that the max tokens indicated in the action input don't take into account the thinking budget tokens.
|
||||
request.max_tokens = request.thinking.budget_tokens + request.max_tokens
|
||||
|
||||
// Note: These params aren't supported in thinking mode, see: https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
|
||||
request.temperature = undefined
|
||||
request.top_k = undefined
|
||||
request.top_p = undefined
|
||||
}
|
||||
|
||||
if (request.max_tokens > model.output.maxTokens) {
|
||||
request.max_tokens = model.output.maxTokens
|
||||
logger
|
||||
.forBot()
|
||||
.warn(
|
||||
`Received maxTokens parameter greater than the maximum output tokens allowed for model "${modelId}", capped parameter value to ${request.max_tokens}`
|
||||
)
|
||||
}
|
||||
|
||||
if (input.debug) {
|
||||
logger.forBot().info('Request being sent to Anthropic: ' + JSON.stringify(request, null, 2))
|
||||
}
|
||||
|
||||
try {
|
||||
response = await anthropic.messages.create(request)
|
||||
} catch (err: any) {
|
||||
if (err instanceof Anthropic.APIError) {
|
||||
const parsedInnerError = AnthropicInnerErrorSchema.safeParse(err.error)
|
||||
if (parsedInnerError.success) {
|
||||
throw llm.createUpstreamProviderFailedError(
|
||||
err,
|
||||
`Anthropic error ${err.status} (${parsedInnerError.data.error.type}) - ${parsedInnerError.data.error.message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
throw llm.createUpstreamProviderFailedError(err)
|
||||
} finally {
|
||||
if (input.debug && response) {
|
||||
logger.forBot().info('Response received from Anthropic: ' + JSON.stringify(response, null, 2))
|
||||
}
|
||||
}
|
||||
|
||||
const { input_tokens: inputTokens, output_tokens: outputTokens } = response.usage
|
||||
const inputCost = calculateTokenCost(model.input.costPer1MTokens, inputTokens)
|
||||
const outputCost = calculateTokenCost(model.output.costPer1MTokens, outputTokens)
|
||||
const cost = inputCost + outputCost
|
||||
|
||||
const content = response.content
|
||||
.filter((x): x is Anthropic.TextBlock => x.type === 'text') // Claude models only return "text", "thinking", or "tool_use" blocks at the moment.
|
||||
.map((content) => content.text)
|
||||
.join('\n\n')
|
||||
|
||||
return {
|
||||
id: response.id,
|
||||
provider: 'anthropic',
|
||||
model: response.model,
|
||||
choices: [
|
||||
// Claude models don't support multiple "choices" or completions, they only return one.
|
||||
{
|
||||
role: response.role,
|
||||
type: 'multipart',
|
||||
index: 0,
|
||||
stopReason: mapToStopReason(response.stop_reason),
|
||||
toolCalls: mapToToolCalls(response),
|
||||
content,
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
inputTokens,
|
||||
inputCost,
|
||||
outputTokens,
|
||||
outputCost,
|
||||
},
|
||||
botpress: {
|
||||
cost, // DEPRECATED
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function calculateTokenCost(costPer1MTokens: number, tokenCount: number) {
|
||||
return (costPer1MTokens / 1_000_000) * tokenCount
|
||||
}
|
||||
|
||||
async function mapToAnthropicMessage(message: llm.Message): Promise<Anthropic.MessageParam> {
|
||||
return {
|
||||
role: message.role,
|
||||
content: await mapToAnthropicMessageContent(message),
|
||||
}
|
||||
}
|
||||
|
||||
async function mapToAnthropicMessageContent(message: llm.Message): Promise<Anthropic.MessageParam['content']> {
|
||||
if (message.type === 'text') {
|
||||
if (typeof message.content !== 'string') {
|
||||
throw new InvalidPayloadError('`content` must be a string when message type is "text"')
|
||||
}
|
||||
|
||||
return message.content as string
|
||||
} else if (message.type === 'multipart') {
|
||||
if (!Array.isArray(message.content)) {
|
||||
throw new InvalidPayloadError('`content` must be an array when message type is "multipart"')
|
||||
}
|
||||
|
||||
return await mapMultipartMessageContentToAnthropicContent(message.content)
|
||||
} else if (message.type === 'tool_calls') {
|
||||
if (!message.toolCalls) {
|
||||
throw new InvalidPayloadError('`toolCalls` is required when message type is "tool_calls"')
|
||||
} else if (message.toolCalls.length === 0) {
|
||||
throw new InvalidPayloadError('`toolCalls` must contain at least one tool call')
|
||||
}
|
||||
|
||||
return message.toolCalls.map(
|
||||
(toolCall) =>
|
||||
<Anthropic.ToolUseBlockParam>{
|
||||
type: 'tool_use',
|
||||
id: toolCall.id,
|
||||
name: toolCall.function.name,
|
||||
input: toolCall.function.arguments,
|
||||
}
|
||||
)
|
||||
} else if (message.type === 'tool_result') {
|
||||
return [
|
||||
<Anthropic.ToolResultBlockParam>{
|
||||
type: 'tool_result',
|
||||
tool_use_id: message.toolResultCallId,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: message.content as string,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
} else {
|
||||
throw new InvalidPayloadError(`Message type "${message.type}" is not supported`)
|
||||
}
|
||||
}
|
||||
|
||||
async function mapMultipartMessageContentToAnthropicContent(
|
||||
content: NonNullable<llm.Message['content']>
|
||||
): Promise<Anthropic.MessageParam['content']> {
|
||||
assert(typeof content !== 'string')
|
||||
|
||||
const anthropicContent: Anthropic.MessageParam['content'] = []
|
||||
|
||||
for (const part of content) {
|
||||
if (part.type === 'text') {
|
||||
anthropicContent.push(<Anthropic.TextBlockParam>{
|
||||
type: 'text',
|
||||
text: part.text,
|
||||
})
|
||||
} else if (part.type === 'image') {
|
||||
if (!part.url) {
|
||||
throw new InvalidPayloadError('`url` is required when part type is "image"')
|
||||
}
|
||||
|
||||
let buffer: Buffer
|
||||
try {
|
||||
const response = await fetch(part.url)
|
||||
buffer = Buffer.from(await response.arrayBuffer())
|
||||
|
||||
const contentTypeHeader = response.headers.get('content-type')
|
||||
if (!part.mimeType && contentTypeHeader) {
|
||||
part.mimeType = contentTypeHeader
|
||||
}
|
||||
} catch (err: any) {
|
||||
throw new InvalidPayloadError(
|
||||
`Failed to retrieve image in message content from the provided URL: ${part.url} (Error: ${err.message})`
|
||||
)
|
||||
}
|
||||
|
||||
anthropicContent.push(<Anthropic.ImageBlockParam>{
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: part.mimeType,
|
||||
data: buffer.toString('base64'),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return anthropicContent
|
||||
}
|
||||
|
||||
function mapToAnthropicTools(input: llm.GenerateContentInput): Anthropic.Tool[] | undefined {
|
||||
if (input.toolChoice?.type === 'none') {
|
||||
// Don't return any tools if tool choice was to not use any tools
|
||||
return []
|
||||
}
|
||||
|
||||
const anthropicTools = input.tools?.map(
|
||||
(tool) =>
|
||||
<Anthropic.Tool>{
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
input_schema: tool.function.argumentsSchema,
|
||||
}
|
||||
)
|
||||
|
||||
// note: don't send an empty tools array
|
||||
return anthropicTools?.length ? anthropicTools : undefined
|
||||
}
|
||||
|
||||
function mapToAnthropicToolChoice(
|
||||
toolChoice: llm.GenerateContentInput['toolChoice']
|
||||
): Anthropic.MessageCreateParams['tool_choice'] {
|
||||
if (!toolChoice) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
switch (toolChoice.type) {
|
||||
case 'any':
|
||||
return <ToolChoiceAny>{ type: 'any' }
|
||||
case 'auto':
|
||||
return <ToolChoiceAuto>{ type: 'auto' }
|
||||
case 'specific':
|
||||
return <ToolChoiceTool>{
|
||||
type: 'tool',
|
||||
name: toolChoice.functionName,
|
||||
}
|
||||
case 'none': // This is handled when passing the tool list by removing all tools, as Anthropic doesn't support a "none" tool choice
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
function mapToStopReason(
|
||||
anthropicStopReason: Anthropic.Message['stop_reason']
|
||||
): llm.GenerateContentOutput['choices'][0]['stopReason'] {
|
||||
switch (anthropicStopReason) {
|
||||
case 'end_turn':
|
||||
case 'stop_sequence':
|
||||
return 'stop'
|
||||
case 'max_tokens':
|
||||
return 'max_tokens'
|
||||
case 'tool_use':
|
||||
return 'tool_calls'
|
||||
default:
|
||||
return 'other'
|
||||
}
|
||||
}
|
||||
|
||||
function mapToToolCalls(response: Anthropic.Message): llm.ToolCall[] | undefined {
|
||||
const toolCalls = response.content.filter((x): x is Anthropic.ToolUseBlock => x.type === 'tool_use')
|
||||
|
||||
return toolCalls.map((toolCall) => {
|
||||
return {
|
||||
id: toolCall.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: toolCall.name,
|
||||
arguments: toolCall.input as llm.ToolCall['function']['arguments'],
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import { llm } from '@botpress/common'
|
||||
import { GenerateContentInput } from '@botpress/common/src/llm'
|
||||
import { generateContent } from './actions/generate-content'
|
||||
import { DefaultModel, ModelId } from './schemas'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const anthropic = new Anthropic({
|
||||
apiKey: bp.secrets.ANTHROPIC_API_KEY,
|
||||
timeout: 10 * 60 * 1000, // 10 minute timeout, we set it here to avoid the error thrown by the Anthropic SDK when not using streaming if the request maxTokens parameters is too high (see: https://github.com/anthropics/anthropic-sdk-typescript?tab=readme-ov-file#long-requests)
|
||||
})
|
||||
|
||||
export type ReasoningEffort = NonNullable<GenerateContentInput['reasoningEffort']>
|
||||
|
||||
export const ThinkingModeBudgetTokens: Record<ReasoningEffort, number> = {
|
||||
none: 0,
|
||||
dynamic: 8192, // Note: Anthropic doesn't support dynamic reasoning, so we default this to the same value as "medium"
|
||||
low: 2048,
|
||||
medium: 8192,
|
||||
high: 16_384,
|
||||
// Note: we cannot go above 20K tokens for the thinking mode budget as that would require us to use streaming, see:
|
||||
// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
|
||||
}
|
||||
|
||||
export const ReasoningModelIdReplacements: Partial<Record<ModelId, ModelId>> = {
|
||||
// These "reasoning" model IDs didn't really exist in Anthropic, we used it as a simple way for users to switch between the reasoning mode and the standard mode, but this approach has been deprecated in favor of specifying a reasoning effort in the request to activate reasoning in the model.
|
||||
'claude-haiku-4-5-reasoning-20251001': 'claude-haiku-4-5-20251001',
|
||||
'claude-sonnet-4-5-reasoning-20250929': 'claude-sonnet-4-5-20250929',
|
||||
'claude-sonnet-4-reasoning-20250514': 'claude-sonnet-4-20250514',
|
||||
}
|
||||
|
||||
const LanguageModels: Record<ModelId, llm.ModelDetails> = {
|
||||
// Reference: https://docs.anthropic.com/en/docs/about-claude/models
|
||||
// NOTE: We don't support returning "thinking" blocks from Claude in the integration action output as the concept of "thinking" blocks is a Claude-specific feature that other providers don't have. For now we won't support this as an official feature in the integration so it needs to be taken into account when using reasoning mode and passing a multi-turn conversation history in the generateContent action input.
|
||||
// For more information, see: https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#preserving-thinking-blocks
|
||||
// NOTE: We intentionally didn't include the Opus model as it's the most expensive model in the market, it's not very popular, and no users have ever requested it so far.
|
||||
'claude-opus-4-7': {
|
||||
name: 'Claude Opus 4.7',
|
||||
description:
|
||||
"Claude Opus 4.7 is Anthropic's most capable model to date. Building on Opus 4.6, it advances frontier coding, agentic reasoning, and enterprise workflows.",
|
||||
tags: ['recommended', 'reasoning', 'agents', 'vision', 'general-purpose', 'coding'],
|
||||
input: {
|
||||
costPer1MTokens: 5,
|
||||
maxTokens: 1_000_000,
|
||||
},
|
||||
output: {
|
||||
costPer1MTokens: 15,
|
||||
maxTokens: 128_000,
|
||||
},
|
||||
},
|
||||
'claude-opus-4-6': {
|
||||
name: 'Claude Opus 4.6',
|
||||
description:
|
||||
'Claude Opus 4.6 is anthropic\s most capable model to date. Building on the intelligence of Opus 4.5, it brings new levels of reliability and precision to coding, agents, and enterprise workflows.',
|
||||
tags: ['recommended', 'reasoning', 'agents', 'vision', 'general-purpose', 'coding'],
|
||||
input: {
|
||||
costPer1MTokens: 5,
|
||||
maxTokens: 1_000_000,
|
||||
},
|
||||
output: {
|
||||
costPer1MTokens: 15,
|
||||
maxTokens: 128_000,
|
||||
},
|
||||
},
|
||||
'claude-sonnet-4-6': {
|
||||
name: 'Claude Sonnet 4.6',
|
||||
description:
|
||||
'Claude Sonnet 4.6 offers the best combination of speed and intelligence in the Claude family. It features adaptive thinking for dynamic reasoning allocation, delivering fast responses for simple queries and deeper analysis for complex tasks.',
|
||||
tags: ['recommended', 'reasoning', 'agents', 'vision', 'general-purpose', 'coding'],
|
||||
input: {
|
||||
costPer1MTokens: 3,
|
||||
maxTokens: 200_000,
|
||||
},
|
||||
output: {
|
||||
costPer1MTokens: 15,
|
||||
maxTokens: 64_000,
|
||||
},
|
||||
},
|
||||
'claude-haiku-4-5-20251001': {
|
||||
name: 'Claude Haiku 4.5',
|
||||
description:
|
||||
"Claude Haiku 4.5 is Anthropic's fastest and most efficient model, delivering near-frontier intelligence at a fraction of the cost and latency of larger Claude models. Matching Claude Sonnet 4's performance across reasoning, coding, and computer-use tasks, Haiku 4.5 brings frontier-level capability to real-time and high-volume applications.",
|
||||
tags: ['recommended', 'agents', 'vision', 'general-purpose', 'coding'],
|
||||
input: {
|
||||
costPer1MTokens: 1,
|
||||
maxTokens: 200_000,
|
||||
},
|
||||
output: {
|
||||
costPer1MTokens: 5,
|
||||
maxTokens: 64_000,
|
||||
},
|
||||
},
|
||||
'claude-haiku-4-5-reasoning-20251001': {
|
||||
name: 'Claude Haiku 4.5 (Reasoning Mode)',
|
||||
description:
|
||||
'This model uses the "Extended Thinking" mode and will use a significantly higher amount of output tokens than the Standard Mode, so this model should only be used for tasks that actually require it.\n\nClaude Haiku 4.5 is Anthropic\'s fastest and most efficient model, delivering near-frontier intelligence at a fraction of the cost and latency of larger Claude models. Matching Claude Sonnet 4\'s performance across reasoning, coding, and computer-use tasks, Haiku 4.5 brings frontier-level capability to real-time and high-volume applications.',
|
||||
tags: ['recommended', 'reasoning', 'agents', 'vision', 'general-purpose', 'coding'],
|
||||
input: {
|
||||
costPer1MTokens: 1,
|
||||
maxTokens: 200_000,
|
||||
},
|
||||
output: {
|
||||
costPer1MTokens: 5,
|
||||
maxTokens: 64_000,
|
||||
},
|
||||
},
|
||||
'claude-sonnet-4-5-20250929': {
|
||||
name: 'Claude Sonnet 4.5',
|
||||
description:
|
||||
"Claude Sonnet 4.5 is Anthropic's most advanced Sonnet model to date, optimized for real-world agents and coding workflows. It delivers state-of-the-art performance on coding benchmarks, with improvements across system design, code security, and specification adherence.",
|
||||
tags: ['recommended', 'agents', 'vision', 'general-purpose', 'coding'],
|
||||
input: {
|
||||
costPer1MTokens: 3,
|
||||
maxTokens: 200_000,
|
||||
},
|
||||
output: {
|
||||
costPer1MTokens: 15,
|
||||
maxTokens: 64_000,
|
||||
},
|
||||
},
|
||||
'claude-sonnet-4-5-reasoning-20250929': {
|
||||
name: 'Claude Sonnet 4.5 (Reasoning Mode)',
|
||||
description:
|
||||
'This model uses the "Extended Thinking" mode and will use a significantly higher amount of output tokens than the Standard Mode, so this model should only be used for tasks that actually require it.\n\nClaude Sonnet 4.5 is Anthropic\'s most advanced Sonnet model to date, optimized for real-world agents and coding workflows. It delivers state-of-the-art performance on coding benchmarks, with improvements across system design, code security, and specification adherence.',
|
||||
tags: ['recommended', 'reasoning', 'agents', 'vision', 'general-purpose', 'coding'],
|
||||
input: {
|
||||
costPer1MTokens: 3,
|
||||
maxTokens: 200_000,
|
||||
},
|
||||
output: {
|
||||
costPer1MTokens: 15,
|
||||
maxTokens: 64_000,
|
||||
},
|
||||
},
|
||||
'claude-sonnet-4-20250514': {
|
||||
name: 'Claude Sonnet 4',
|
||||
description:
|
||||
'Claude Sonnet 4 significantly enhances the capabilities of its predecessor, Sonnet 3.7, excelling in both coding and reasoning tasks with improved precision and controllability. Sonnet 4 balances capability and computational efficiency, making it suitable for a broad range of applications from routine coding tasks to complex software development projects. Key enhancements include improved autonomous codebase navigation, reduced error rates in agent-driven workflows, and increased reliability in following intricate instructions.',
|
||||
tags: ['agents', 'vision', 'general-purpose', 'coding'],
|
||||
input: {
|
||||
costPer1MTokens: 3,
|
||||
maxTokens: 200_000,
|
||||
},
|
||||
output: {
|
||||
costPer1MTokens: 15,
|
||||
maxTokens: 64_000,
|
||||
},
|
||||
},
|
||||
'claude-sonnet-4-reasoning-20250514': {
|
||||
name: 'Claude Sonnet 4 (Reasoning Mode)',
|
||||
description:
|
||||
'This model uses the "Extended Thinking" mode and will use a significantly higher amount of output tokens than the Standard Mode, so this model should only be used for tasks that actually require it.\n\nClaude Sonnet 4 significantly enhances the capabilities of its predecessor, Sonnet 3.7, excelling in both coding and reasoning tasks with improved precision and controllability. Sonnet 4 balances capability and computational efficiency, making it suitable for a broad range of applications from routine coding tasks to complex software development projects. Key enhancements include improved autonomous codebase navigation, reduced error rates in agent-driven workflows, and increased reliability in following intricate instructions.',
|
||||
tags: ['vision', 'reasoning', 'general-purpose', 'agents', 'coding'],
|
||||
input: {
|
||||
costPer1MTokens: 3,
|
||||
maxTokens: 200_000,
|
||||
},
|
||||
output: {
|
||||
costPer1MTokens: 15,
|
||||
maxTokens: 64_000,
|
||||
},
|
||||
},
|
||||
'claude-3-5-sonnet-20241022': {
|
||||
name: 'Claude 3.5 Sonnet (October 2024)',
|
||||
description:
|
||||
'Claude 3.5 Sonnet delivers better-than-Opus capabilities, faster-than-Sonnet speeds, at the same Sonnet prices. Sonnet is particularly good at coding, data science, visual processing, and agentic tasks.',
|
||||
tags: ['vision', 'general-purpose', 'agents', 'coding', 'function-calling', 'storytelling'],
|
||||
input: {
|
||||
costPer1MTokens: 3,
|
||||
maxTokens: 200_000,
|
||||
},
|
||||
output: {
|
||||
costPer1MTokens: 15,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
},
|
||||
'claude-3-5-sonnet-20240620': {
|
||||
name: 'Claude 3.5 Sonnet (June 2024)',
|
||||
description:
|
||||
'Claude 3.5 Sonnet delivers better-than-Opus capabilities, faster-than-Sonnet speeds, at the same Sonnet prices. Sonnet is particularly good at coding, data science, visual processing, and agentic tasks.',
|
||||
tags: ['vision', 'general-purpose', 'agents', 'coding', 'function-calling', 'storytelling'],
|
||||
input: {
|
||||
costPer1MTokens: 3,
|
||||
maxTokens: 200_000,
|
||||
},
|
||||
output: {
|
||||
costPer1MTokens: 15,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default new bp.Integration({
|
||||
register: async () => {},
|
||||
unregister: async () => {},
|
||||
actions: {
|
||||
generateContent: async ({ input, logger, metadata }) => {
|
||||
const output = await generateContent(<llm.GenerateContentInput>input, anthropic, logger, {
|
||||
models: LanguageModels,
|
||||
defaultModel: DefaultModel,
|
||||
})
|
||||
metadata.setCost(output.botpress.cost)
|
||||
return output
|
||||
},
|
||||
listLanguageModels: async ({}) => {
|
||||
return {
|
||||
models: Object.entries(LanguageModels).map(([id, model]) => ({ id: <ModelId>id, ...model })),
|
||||
}
|
||||
},
|
||||
},
|
||||
channels: {},
|
||||
handler: async () => {},
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
|
||||
export type ModelId = z.infer<typeof ModelId>
|
||||
|
||||
export const DefaultModel: ModelId = 'claude-sonnet-4-5-20250929'
|
||||
|
||||
export const ModelId = z
|
||||
.enum([
|
||||
'claude-opus-4-7',
|
||||
'claude-opus-4-6',
|
||||
'claude-sonnet-4-6',
|
||||
'claude-haiku-4-5-20251001',
|
||||
'claude-haiku-4-5-reasoning-20251001',
|
||||
'claude-sonnet-4-5-20250929',
|
||||
'claude-sonnet-4-5-reasoning-20250929',
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-sonnet-4-reasoning-20250514',
|
||||
'claude-3-5-sonnet-20241022',
|
||||
'claude-3-5-sonnet-20240620',
|
||||
])
|
||||
.describe('Model to use for content generation')
|
||||
.placeholder(DefaultModel)
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import config from '../../vitest.config'
|
||||
export default config
|
||||
@@ -0,0 +1 @@
|
||||
.botpress
|
||||
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
The Asana Integration facilitates the connection between your Botpress and Asana, a widely used project management platform. By utilizing this integration, you can effortlessly oversee your projects and tasks directly from your chatbot interface.
|
||||
|
||||
## Setup
|
||||
|
||||
To establish this integration, you will need to provide your **Asana API token** and **workspace ID**. After the setup, you can make use of the built-in functionalities to perform actions such as task creation, task updates, task comment addition, and more.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before activating the Botpress Asana Integration, please ensure the availability of the following:
|
||||
|
||||
- Authorized access to an existing Asana workspace.
|
||||
|
||||
- A valid API token generated from Asana (Personal Access Token).
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg width="110" height="102" viewBox="0 0 110 102" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_370_36560)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M86.0769 53.9995C72.8649 53.9995 62.1537 64.7445 62.1537 78.0002C62.1537 91.255 72.8649 102 86.0769 102C99.2893 102 110 91.255 110 78.0002C110 64.7445 99.2893 53.9995 86.0769 53.9995ZM23.923 54.0018C10.7108 54.0018 0 64.7445 0 78.0002C0 91.255 10.7108 102 23.923 102C37.1358 102 47.8472 91.255 47.8472 78.0002C47.8472 64.7445 37.1358 54.0018 23.923 54.0018ZM78.9227 23.9992C78.9227 37.2548 68.2125 48.001 55 48.001C41.7873 48.001 31.0771 37.2548 31.0771 23.9992C31.0771 10.7462 41.7873 0 55 0C68.2125 0 78.9227 10.7462 78.9227 23.9992Z" fill="#F06A6A"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_370_36560">
|
||||
<rect width="110" height="102" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 858 B |
@@ -0,0 +1,23 @@
|
||||
import { IntegrationDefinition } from '@botpress/sdk'
|
||||
|
||||
import { INTEGRATION_NAME } from './src/const'
|
||||
import { configuration, states, user, channels, actions } from './src/definitions'
|
||||
|
||||
export default new IntegrationDefinition({
|
||||
name: INTEGRATION_NAME,
|
||||
version: '0.3.14',
|
||||
title: 'Asana',
|
||||
readme: 'hub.md',
|
||||
description: 'Connect your bot to your Asana inbox, create and update tasks, add comments, and locate users.',
|
||||
icon: 'icon.svg',
|
||||
configuration,
|
||||
channels,
|
||||
user,
|
||||
actions,
|
||||
states,
|
||||
|
||||
attributes: {
|
||||
category: 'Project Management',
|
||||
repo: 'botpress',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@botpresshub/asana",
|
||||
"description": "Asana integration for Botpress",
|
||||
"scripts": {
|
||||
"build": "bp add -y && bp build",
|
||||
"check:type": "tsc --noEmit",
|
||||
"check:bplint": "bp lint",
|
||||
"test": "vitest --run"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"@botpress/sdk-addons": "workspace:*",
|
||||
"asana": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/client": "workspace:*",
|
||||
"@botpress/common": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"@types/asana": "^0.18.12"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { addCommentToTaskInputSchema } from '../misc/custom-schemas'
|
||||
import type { IntegrationProps } from '../misc/types'
|
||||
|
||||
import { getClient } from '../utils'
|
||||
|
||||
export const addCommentToTask: IntegrationProps['actions']['addCommentToTask'] = async ({ ctx, input, logger }) => {
|
||||
const validatedInput = addCommentToTaskInputSchema.parse(input)
|
||||
const asanaClient = getClient(ctx.configuration)
|
||||
let response
|
||||
try {
|
||||
response = await asanaClient.addCommentToTask(validatedInput.taskId, validatedInput.comment)
|
||||
logger.forBot().info(`Successful - Add Comment to Task - ${response.text}`)
|
||||
} catch (error) {
|
||||
logger.forBot().debug(`'Add Comment to Task' exception ${JSON.stringify(error)}`)
|
||||
response = {}
|
||||
}
|
||||
return { text: response.text || '' }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createTaskInputSchema } from '../misc/custom-schemas'
|
||||
import type { IntegrationProps } from '../misc/types'
|
||||
|
||||
import { getClient } from '../utils'
|
||||
|
||||
export const createTask: IntegrationProps['actions']['createTask'] = async ({ ctx, input, logger }) => {
|
||||
const validatedInput = createTaskInputSchema.parse(input)
|
||||
const asanaClient = getClient(ctx.configuration)
|
||||
const task = {
|
||||
workspace: ctx.configuration.workspaceGid,
|
||||
name: validatedInput.name,
|
||||
notes: validatedInput.notes || undefined,
|
||||
assignee: validatedInput.assignee || undefined,
|
||||
projects: validatedInput.projects?.split(',').map((project) => project.trim()) || undefined,
|
||||
parent: validatedInput.parent || undefined,
|
||||
due_on: validatedInput.due_on || validatedInput.start_on || undefined,
|
||||
start_on: validatedInput.start_on || undefined,
|
||||
}
|
||||
let response
|
||||
try {
|
||||
response = await asanaClient.createTask(task)
|
||||
logger.forBot().info(`Successful - Create Task - ${response.permalink_url}`)
|
||||
} catch (error) {
|
||||
logger.forBot().debug(`'Create Task' exception ${JSON.stringify(error)}`)
|
||||
response = {}
|
||||
}
|
||||
return { permalink_url: response.permalink_url || '' }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { findUserInputSchema } from '../misc/custom-schemas'
|
||||
import type { IntegrationProps } from '../misc/types'
|
||||
|
||||
import { getClient } from '../utils'
|
||||
|
||||
export const findUser: IntegrationProps['actions']['findUser'] = async ({ ctx, input, logger }) => {
|
||||
const validatedInput = findUserInputSchema.parse(input)
|
||||
const asanaClient = getClient(ctx.configuration)
|
||||
let response
|
||||
try {
|
||||
response = await asanaClient.findUser(validatedInput.userEmail)
|
||||
logger.forBot().info(`Successful - Find User - ${response.name}`)
|
||||
} catch (error) {
|
||||
logger.forBot().debug(`'Find User' exception ${JSON.stringify(error)}`)
|
||||
response = {}
|
||||
}
|
||||
return response
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { addCommentToTask } from './add-comment-to-task'
|
||||
import { createTask } from './create-task'
|
||||
import { findUser } from './find-user'
|
||||
import { updateTask } from './update-task'
|
||||
|
||||
export default { createTask, updateTask, findUser, addCommentToTask }
|
||||
@@ -0,0 +1,26 @@
|
||||
import { updateTaskInputSchema } from '../misc/custom-schemas'
|
||||
import type { IntegrationProps } from '../misc/types'
|
||||
|
||||
import { getClient } from '../utils'
|
||||
|
||||
export const updateTask: IntegrationProps['actions']['updateTask'] = async ({ ctx, input, logger }) => {
|
||||
const validatedInput = updateTaskInputSchema.parse(input)
|
||||
const asanaClient = getClient(ctx.configuration)
|
||||
const task = {
|
||||
name: validatedInput.name,
|
||||
notes: validatedInput.notes || undefined,
|
||||
assignee: validatedInput.assignee || undefined,
|
||||
due_on: validatedInput.due_on || validatedInput.start_on || undefined,
|
||||
start_on: validatedInput.start_on || undefined,
|
||||
completed: validatedInput.completed === 'true' ? true : undefined,
|
||||
}
|
||||
let response
|
||||
try {
|
||||
response = await asanaClient.updateTask(validatedInput.taskId, task)
|
||||
logger.forBot().info(`Successful - Update Task - ${response.permalink_url}`)
|
||||
} catch (error) {
|
||||
logger.forBot().debug(`'Update Task' exception ${JSON.stringify(error)}`)
|
||||
response = {}
|
||||
}
|
||||
return { permalink_url: response.permalink_url || '' }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as asana from 'asana'
|
||||
|
||||
export class AsanaApi {
|
||||
private _client: asana.Client
|
||||
|
||||
public constructor(apiToken: string) {
|
||||
this._client = asana.Client.create({
|
||||
defaultHeaders: {
|
||||
'Asana-Enable': 'new_goal_memberships,new_user_task_lists',
|
||||
},
|
||||
}).useAccessToken(apiToken)
|
||||
}
|
||||
|
||||
public async createTask(
|
||||
task: asana.resources.Tasks.CreateParams & { workspace: string }
|
||||
): Promise<asana.resources.Tasks.Type> {
|
||||
return await this._client.tasks.create(task)
|
||||
}
|
||||
|
||||
public async updateTask(
|
||||
taskId: string,
|
||||
task: asana.resources.Tasks.UpdateParams
|
||||
): Promise<asana.resources.Tasks.Type> {
|
||||
return await this._client.tasks.update(taskId, task)
|
||||
}
|
||||
|
||||
public async addCommentToTask(taskId: string, comment: string): Promise<asana.resources.Stories.Type> {
|
||||
return await this._client.tasks.addComment(taskId, { text: comment })
|
||||
}
|
||||
|
||||
public async findUser(userEmail: string): Promise<asana.resources.Users.Type> {
|
||||
return await this._client.users.findById(userEmail)
|
||||
}
|
||||
|
||||
public async findAllUsers(workspace: string): Promise<asana.resources.Users.Type[]> {
|
||||
const users = await this._client.users.findAll({ workspace })
|
||||
return users.data as asana.resources.Users.Type[]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const INTEGRATION_NAME = 'asana'
|
||||
@@ -0,0 +1,65 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import {
|
||||
createTaskInputSchema,
|
||||
createTaskOutputSchema,
|
||||
updateTaskInputSchema,
|
||||
updateTaskOutputSchema,
|
||||
findUserInputSchema,
|
||||
findUserOutputSchema,
|
||||
addCommentToTaskInputSchema,
|
||||
addCommentToTaskOutputSchema,
|
||||
} from '../misc/custom-schemas'
|
||||
|
||||
type SdkActions = NonNullable<sdk.IntegrationDefinitionProps['actions']>
|
||||
type SdkAction = SdkActions[string]
|
||||
|
||||
const createTask = {
|
||||
title: 'Create Task',
|
||||
description: 'Create Task',
|
||||
input: {
|
||||
schema: createTaskInputSchema,
|
||||
},
|
||||
output: {
|
||||
schema: createTaskOutputSchema,
|
||||
},
|
||||
} satisfies SdkAction
|
||||
|
||||
const updateTask = {
|
||||
title: 'Update Task',
|
||||
description: 'Update Task by taskId',
|
||||
input: {
|
||||
schema: updateTaskInputSchema,
|
||||
},
|
||||
output: {
|
||||
schema: updateTaskOutputSchema,
|
||||
},
|
||||
} satisfies SdkAction
|
||||
|
||||
const findUser = {
|
||||
title: 'Find User',
|
||||
description: 'Find User by userId',
|
||||
input: {
|
||||
schema: findUserInputSchema,
|
||||
},
|
||||
output: {
|
||||
schema: findUserOutputSchema,
|
||||
},
|
||||
} satisfies SdkAction
|
||||
|
||||
const addCommentToTask = {
|
||||
title: 'Add Comment to Task',
|
||||
description: 'Add Comment to Task, by task ID',
|
||||
input: {
|
||||
schema: addCommentToTaskInputSchema,
|
||||
},
|
||||
output: {
|
||||
schema: addCommentToTaskOutputSchema,
|
||||
},
|
||||
} satisfies SdkAction
|
||||
|
||||
export const actions = {
|
||||
createTask,
|
||||
updateTask,
|
||||
findUser,
|
||||
addCommentToTask,
|
||||
} satisfies SdkActions
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IntegrationDefinitionProps, messages } from '@botpress/sdk'
|
||||
|
||||
export const channels = {
|
||||
channel: {
|
||||
title: 'Channel',
|
||||
description: 'The channel for Asana',
|
||||
messages: {
|
||||
...messages.defaults,
|
||||
markdown: messages.markdown,
|
||||
bloc: messages.markdownBloc,
|
||||
},
|
||||
},
|
||||
} satisfies IntegrationDefinitionProps['channels']
|
||||
@@ -0,0 +1,32 @@
|
||||
import { z, IntegrationDefinitionProps } from '@botpress/sdk'
|
||||
|
||||
export { actions } from './actions'
|
||||
export { channels } from './channels'
|
||||
|
||||
export const configuration = {
|
||||
schema: z.object({
|
||||
apiToken: z.string().min(1).title('API Token').describe('API Token'),
|
||||
workspaceGid: z.string().min(1).title('Workspace Global ID').describe('Workspace Global ID'),
|
||||
}),
|
||||
} satisfies IntegrationDefinitionProps['configuration']
|
||||
|
||||
export const states = {
|
||||
// voidStateOne: {
|
||||
// type: 'integration',
|
||||
// schema: z.object({
|
||||
// dataField: z.string(),
|
||||
// }),
|
||||
// },
|
||||
// voidStateTwo: {
|
||||
// type: 'conversation',
|
||||
// schema: z.object({
|
||||
// otherDataField: z.string(),
|
||||
// }),
|
||||
// },
|
||||
} satisfies IntegrationDefinitionProps['states']
|
||||
|
||||
export const user = {
|
||||
tags: {
|
||||
// id: {},
|
||||
},
|
||||
} satisfies IntegrationDefinitionProps['user']
|
||||
@@ -0,0 +1,14 @@
|
||||
import { reporting } from '@botpress/sdk-addons'
|
||||
import actions from './actions'
|
||||
import { register, unregister, channels, handler } from './setup'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const integration = new bp.Integration({
|
||||
register,
|
||||
unregister,
|
||||
actions,
|
||||
channels,
|
||||
handler,
|
||||
})
|
||||
|
||||
export default reporting.wrapIntegration(integration)
|
||||
@@ -0,0 +1,95 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
import { photoSchema, workspaceSchema } from './sub-schemas'
|
||||
|
||||
export const createTaskInputSchema = z.object({
|
||||
name: z.string().describe('The name of the task (e.g. "My Test Task")').title('Name'),
|
||||
notes: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The description of the task (Optional) (e.g. "This is my other task created using the Asana API")')
|
||||
.title('Notes'),
|
||||
assignee: z
|
||||
.string()
|
||||
.optional()
|
||||
.default('me')
|
||||
.describe(
|
||||
'The ID of the user who will be assigned to the task or "me" to assign to the current user (Optional) (e.g. "1215207682932839") (Default: "me")'
|
||||
)
|
||||
.title('Assignee'),
|
||||
projects: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'The project IDs should be strings separated by commas (Optional) (e.g. "1205199808673678, 1215207282932839").'
|
||||
)
|
||||
.title('Projects'),
|
||||
parent: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The ID of the parent task (Optional) (e.g. "1205206556256028")')
|
||||
.title('Parent'),
|
||||
start_on: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The start date of the task in YYYY-MM-DD format (Optional) (e.g. "2023-08-13")')
|
||||
.title('Start On'),
|
||||
due_on: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The due date of the task without a specific time in YYYY-MM-DD format (Optional) (e.g. "2023-08-15")')
|
||||
.title('Due On'),
|
||||
})
|
||||
|
||||
export const taskOutputSchema = z.object({
|
||||
permalink_url: z.string().describe('The permalink url').title('Permalink Url'),
|
||||
})
|
||||
export const createTaskOutputSchema = taskOutputSchema
|
||||
|
||||
export const updateTaskInputSchema = createTaskInputSchema
|
||||
.omit({
|
||||
projects: true,
|
||||
parent: true,
|
||||
})
|
||||
.extend({
|
||||
taskId: z.string().describe('Task ID to update').title('Task ID'),
|
||||
name: z.string().optional().describe('The name of the task (Optional) (e.g. "My Test Task")').title('Name'),
|
||||
assignee: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'The ID of the user who will be assigned to the task or "me" to assign to the current user (Optional) (e.g. "1215207682932839")'
|
||||
)
|
||||
.title('Assignee'),
|
||||
completed: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'If the task is completed, enter "true" (without quotes), otherwise it will keep its previous status. (Optional)'
|
||||
)
|
||||
.title('Completed'),
|
||||
})
|
||||
export const updateTaskOutputSchema = taskOutputSchema
|
||||
|
||||
export const findUserInputSchema = z.object({
|
||||
userEmail: z.string().describe('User Email (e.g. "mrsomebody@example.com")').title('User Email'),
|
||||
})
|
||||
|
||||
export const findUserOutputSchema = z
|
||||
.object({
|
||||
gid: z.string().describe('The GID of the User').title('GID'),
|
||||
name: z.string().describe('The name of the user').title('Name'),
|
||||
email: z.string().describe('The email of the user').title('Email'),
|
||||
photo: photoSchema.describe('The photo of the user').title('Photo'),
|
||||
resource_type: z.string().describe('The resource type of the user').title('Resource Type'),
|
||||
workspaces: z.array(workspaceSchema).describe('List of the workspaces').title('Workspaces'),
|
||||
})
|
||||
.partial()
|
||||
|
||||
export const addCommentToTaskInputSchema = z.object({
|
||||
taskId: z.string().describe('Task ID to comment').title('Task ID'),
|
||||
comment: z.string().describe('Content of the comment to be added').title('Comment'),
|
||||
})
|
||||
|
||||
export const addCommentToTaskOutputSchema = z.object({
|
||||
text: z.string().describe('The text of the comment').title('Text'),
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
|
||||
const workspaceSchema = z.object({
|
||||
gid: z.string().describe('The GID of the workspace').title('GID'),
|
||||
name: z.string().describe('The name of the workspace').title('Name'),
|
||||
resource_type: z.string().describe('The resource type of the workspace').title('Resource Type'),
|
||||
})
|
||||
|
||||
const photoSchema = z
|
||||
.object({
|
||||
image_21x21: z.string().describe('An Image 21 by 21').title('Image 21x21'),
|
||||
image_27x27: z.string().describe('An Image 27 by 27').title('Image 27x27'),
|
||||
image_36x36: z.string().describe('An Image 36 by 36').title('Image 36x36'),
|
||||
image_60x60: z.string().describe('An Image 60 by 60').title('Image 60x60'),
|
||||
image_128x128: z.string().describe('An Image 128 by 128').title('Image 128x128'),
|
||||
})
|
||||
.nullable()
|
||||
|
||||
export { workspaceSchema, photoSchema }
|
||||
@@ -0,0 +1,9 @@
|
||||
import * 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']
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Channels } from '../misc/types'
|
||||
|
||||
class NotImplementedError extends Error {
|
||||
public constructor() {
|
||||
super('Not implemented')
|
||||
}
|
||||
}
|
||||
|
||||
export const channels: Channels = {
|
||||
channel: {
|
||||
messages: {
|
||||
text: async () => {
|
||||
throw new NotImplementedError()
|
||||
},
|
||||
image: async () => {
|
||||
throw new NotImplementedError()
|
||||
},
|
||||
markdown: async () => {
|
||||
throw new NotImplementedError()
|
||||
},
|
||||
audio: async () => {
|
||||
throw new NotImplementedError()
|
||||
},
|
||||
video: async () => {
|
||||
throw new NotImplementedError()
|
||||
},
|
||||
file: async () => {
|
||||
throw new NotImplementedError()
|
||||
},
|
||||
location: async () => {
|
||||
throw new NotImplementedError()
|
||||
},
|
||||
carousel: async () => {
|
||||
throw new NotImplementedError()
|
||||
},
|
||||
card: async () => {
|
||||
throw new NotImplementedError()
|
||||
},
|
||||
choice: async () => {
|
||||
throw new NotImplementedError()
|
||||
},
|
||||
dropdown: async () => {
|
||||
throw new NotImplementedError()
|
||||
},
|
||||
bloc: async () => {
|
||||
throw new NotImplementedError()
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Handler } from '../misc/types'
|
||||
|
||||
class NotImplementedError extends Error {
|
||||
public constructor() {
|
||||
super('Not implemented')
|
||||
}
|
||||
}
|
||||
|
||||
export const handler: Handler = async () => {
|
||||
throw new NotImplementedError()
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { register } from './register'
|
||||
export { unregister } from './unregister'
|
||||
export { channels } from './channels'
|
||||
export { handler } from './handler'
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { RegisterFunction } from '../misc/types'
|
||||
|
||||
export const register: RegisterFunction = async () => {
|
||||
/**
|
||||
* This is called when an integration configuration is saved.
|
||||
* You should use this handler to instanciate ressources in the external service and ensure that the configuration is valid.
|
||||
*/
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { UnregisterFunction } from '../misc/types'
|
||||
|
||||
export const unregister: UnregisterFunction = async () => {
|
||||
/**
|
||||
* This is called when a bot removes the integration.
|
||||
* You should use this handler to instanciate ressources in the external service and ensure that the configuration is valid.
|
||||
*/
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { AsanaApi } from '../client'
|
||||
import type { Configuration } from '../misc/types'
|
||||
|
||||
export const getClient = (config: Configuration) => new AsanaApi(config.apiToken)
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import config from '../../vitest.config'
|
||||
export default config
|
||||
@@ -0,0 +1,243 @@
|
||||
import { ActionDefinition, z } from '@botpress/sdk'
|
||||
import { baseIdentifierSchema } from './common'
|
||||
|
||||
const recordSchema = z
|
||||
.object({
|
||||
id: baseIdentifierSchema.describe('The record identifier'),
|
||||
created_at: z.string().title('Created At').describe('RFC3339 timestamp when the record was created'),
|
||||
web_url: z.string().title('Web URL').describe('URL of the record in Attio UI'),
|
||||
values: z
|
||||
.record(z.any())
|
||||
.title('Values')
|
||||
.describe('Map of attribute slug or ID to its value (single value or array of values)'),
|
||||
})
|
||||
.title('Record')
|
||||
|
||||
// Objects & Attributes
|
||||
const objectSchema = z
|
||||
.object({
|
||||
id: baseIdentifierSchema.optional().describe('The object identifier'),
|
||||
api_slug: z.string().optional().title('API Slug').describe('The API slug for the object'),
|
||||
singular_noun: z.string().optional().title('Singular Noun').describe('The singular form of the object name'),
|
||||
plural_noun: z.string().optional().title('Plural Noun').describe('The plural form of the object name'),
|
||||
created_at: z.string().optional().title('Created At').describe('RFC3339 timestamp when the object was created'),
|
||||
})
|
||||
.title('Object')
|
||||
|
||||
const attributeSchema = z
|
||||
.object({
|
||||
id: baseIdentifierSchema
|
||||
.extend({ attribute_id: z.string().optional().title('Attribute ID').describe('The attribute identifier') })
|
||||
.optional()
|
||||
.describe('The attribute identifier object'),
|
||||
title: z.string().optional().title('Title').describe('The title of the attribute'),
|
||||
description: z.string().nullable().optional().title('Description').describe('The description of the attribute'),
|
||||
api_slug: z.string().optional().title('API Slug').describe('The API slug for the attribute'),
|
||||
type: z.string().optional().title('Type').describe('The type of the attribute'),
|
||||
slug: z.string().optional().title('Slug').describe('The slug for the attribute'),
|
||||
options: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().optional().title('ID').describe('The option identifier'),
|
||||
label: z.string().optional().title('Label').describe('The label of the option'),
|
||||
name: z.string().optional().title('Name').describe('The name of the option'),
|
||||
value: z.string().optional().title('Value').describe('The value of the option'),
|
||||
title: z.string().optional().title('Title').describe('The title of the option'),
|
||||
slug: z.string().optional().title('Slug').describe('The slug of the option'),
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
.title('Options')
|
||||
.describe('The list of options for the attribute'),
|
||||
})
|
||||
.title('Attribute')
|
||||
|
||||
const listRecords: ActionDefinition = {
|
||||
title: 'List Records',
|
||||
description: 'List records of an Attio object with optional filters, sorts and pagination',
|
||||
input: {
|
||||
schema: z.object({
|
||||
object: z.string().min(1).title('Object').describe("Object slug or UUID, e.g. 'people'"),
|
||||
filter: z
|
||||
.array(
|
||||
z.object({
|
||||
attribute: z.string().min(1).title('Attribute').describe('The attribute to filter by'),
|
||||
value: z.string().min(1).title('Value').describe('The value to filter for'),
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
.title('Filter')
|
||||
.describe('Filtering object. See Attio Shorthand Filtering guide'),
|
||||
sorts: z
|
||||
.array(
|
||||
z.object({
|
||||
direction: z.enum(['asc', 'desc']).title('Direction').describe('The sort direction'),
|
||||
attribute: z.string().min(1).title('Attribute').describe('The attribute to sort by'),
|
||||
field: z.string().min(1).title('Field').describe('The field to sort by'),
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
.title('Sorting')
|
||||
.describe('Sorting instructions. See Attio sorting guide'),
|
||||
limit: z.number().optional().title('Limit').describe('Max number of records to return (default 500)'),
|
||||
offset: z.number().optional().title('Offset').describe('Number of records to skip (default 0)'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z
|
||||
.object({
|
||||
data: z.array(recordSchema).title('Records').describe('List of records'),
|
||||
})
|
||||
.title('List Records Response'),
|
||||
},
|
||||
}
|
||||
|
||||
const getRecord: ActionDefinition = {
|
||||
title: 'Get Record',
|
||||
description: 'Get a single record by object and record ID',
|
||||
input: {
|
||||
schema: z.object({
|
||||
object: z.string().min(1).title('Object').describe('Object slug or UUID'),
|
||||
id: baseIdentifierSchema
|
||||
.extend({ record_id: z.string().min(1).title('Record ID').describe('Record UUID') })
|
||||
.describe('The record identifier'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z
|
||||
.object({
|
||||
data: z
|
||||
.object({
|
||||
id: baseIdentifierSchema.title('Identifier').describe('The fetched identifier'),
|
||||
web_url: z.string().title('Web URL').describe('URL of the record in Attio UI'),
|
||||
values: z.record(z.any()).title('Values').describe('Map of attribute slug/ID to value(s)'),
|
||||
created_at: z.string().title('Created At').describe('RFC3339 timestamp when the record was created'),
|
||||
})
|
||||
.title('Data')
|
||||
.describe('The fetched record data'),
|
||||
})
|
||||
.title('Get Record Response'),
|
||||
},
|
||||
}
|
||||
|
||||
const createRecord: ActionDefinition = {
|
||||
title: 'Create Record',
|
||||
description: 'Create a new record in an Attio object',
|
||||
input: {
|
||||
schema: z.object({
|
||||
object: z.string().min(1).title('Object').describe('Object slug or UUID'),
|
||||
data: z
|
||||
.object({
|
||||
values: z
|
||||
.array(
|
||||
z.object({
|
||||
attribute: z.string().min(1).title('Attribute').describe('The attribute slug or ID'),
|
||||
value: z.string().min(1).title('Value').describe('The value to set for the attribute'),
|
||||
})
|
||||
)
|
||||
.title('Values')
|
||||
.describe('Array of attribute slug/ID to value(s)'),
|
||||
})
|
||||
.title('Data')
|
||||
.describe('The record data to create'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z
|
||||
.object({
|
||||
data: recordSchema.title('Record').describe('The created record'),
|
||||
})
|
||||
.title('Create Record Response'),
|
||||
},
|
||||
}
|
||||
|
||||
const updateRecord: ActionDefinition = {
|
||||
title: 'Update Record',
|
||||
description: 'Update an existing record by object and record ID',
|
||||
input: {
|
||||
schema: z.object({
|
||||
object: z.string().min(1).title('Object').describe('Object slug or UUID'),
|
||||
id: baseIdentifierSchema
|
||||
.extend({ record_id: z.string().min(1).title('Record ID').describe('Record UUID') })
|
||||
.describe('The record identifier'),
|
||||
data: z
|
||||
.object({
|
||||
values: z
|
||||
.array(
|
||||
z.object({
|
||||
attribute: z.string().min(1).title('Attribute').describe('The attribute slug or ID'),
|
||||
value: z.string().min(1).title('Value').describe('The value to set for the attribute'),
|
||||
})
|
||||
)
|
||||
.title('Values')
|
||||
.describe('Array of attribute slug/ID to value(s) to upsert'),
|
||||
})
|
||||
.title('Data')
|
||||
.describe('The record data to update'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z
|
||||
.object({
|
||||
data: recordSchema.title('Record').describe('The updated record'),
|
||||
})
|
||||
.title('Update Record Response'),
|
||||
},
|
||||
}
|
||||
|
||||
const listObjects: ActionDefinition = {
|
||||
title: 'List Objects',
|
||||
description: 'List Attio objects in the workspace',
|
||||
input: { schema: z.object({}) },
|
||||
output: {
|
||||
schema: z
|
||||
.object({
|
||||
data: z.array(objectSchema).title('Objects').describe('List of objects'),
|
||||
})
|
||||
.title('List Objects Response'),
|
||||
},
|
||||
}
|
||||
|
||||
const getObject: ActionDefinition = {
|
||||
title: 'Get Object',
|
||||
description: 'Get a single Attio object by slug or ID',
|
||||
input: {
|
||||
schema: z.object({
|
||||
object: z.string().min(1).title('Object').describe('Object slug or UUID'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z
|
||||
.object({
|
||||
data: objectSchema.title('Object').describe('The requested object'),
|
||||
})
|
||||
.title('Get Object Response'),
|
||||
},
|
||||
}
|
||||
|
||||
const listAttributes: ActionDefinition = {
|
||||
title: 'List Attributes',
|
||||
description: 'List attributes for a given Attio object',
|
||||
input: {
|
||||
schema: z.object({
|
||||
object: z.string().min(1).title('Object').describe('Object slug or UUID'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z
|
||||
.object({
|
||||
data: z.array(attributeSchema).title('Attributes').describe('List of attributes'),
|
||||
})
|
||||
.title('List Attributes Response'),
|
||||
},
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
listRecords,
|
||||
getRecord,
|
||||
createRecord,
|
||||
updateRecord,
|
||||
listObjects,
|
||||
getObject,
|
||||
listAttributes,
|
||||
} as const
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
|
||||
export const baseIdentifierSchema = z
|
||||
.object({
|
||||
workspace_id: z.string().title('Workspace ID').describe('The Attio workspace ID'),
|
||||
object_id: z.string().title('Object ID').describe('The Attio object ID'),
|
||||
})
|
||||
.title('Record Identifier')
|
||||
@@ -0,0 +1,46 @@
|
||||
import { EventDefinition, z } from '@botpress/sdk'
|
||||
import { baseIdentifierSchema } from './common'
|
||||
|
||||
const recordEventSchema = z.object({
|
||||
event_type: z.string().title('Event Type').describe('The type of event'),
|
||||
id: baseIdentifierSchema
|
||||
.extend({ record_id: z.string().title('Record ID').describe('The record identifier') })
|
||||
.describe('The record identifier object'),
|
||||
actor: z
|
||||
.object({
|
||||
type: z.string().title('Actor Type').describe('The type of actor (e.g., workspace-member)'),
|
||||
id: z.string().title('Actor ID').describe('The actor identifier'),
|
||||
})
|
||||
.title('Actor')
|
||||
.describe('The actor who triggered the event'),
|
||||
})
|
||||
|
||||
const recordCreated: EventDefinition = {
|
||||
title: 'Record Created',
|
||||
description: 'A new record has been created in Attio',
|
||||
schema: recordEventSchema.extend({
|
||||
event_type: z.literal('record.created').title('Event Type').describe('The type of event'),
|
||||
}),
|
||||
}
|
||||
|
||||
const recordUpdated: EventDefinition = {
|
||||
title: 'Record Updated',
|
||||
description: 'A record has been updated in Attio',
|
||||
schema: recordEventSchema.extend({
|
||||
event_type: z.literal('record.updated').title('Event Type').describe('The type of event'),
|
||||
}),
|
||||
}
|
||||
|
||||
const recordDeleted: EventDefinition = {
|
||||
title: 'Record Deleted',
|
||||
description: 'A record has been deleted in Attio',
|
||||
schema: recordEventSchema.extend({
|
||||
event_type: z.literal('record.deleted').title('Event Type').describe('The type of event'),
|
||||
}),
|
||||
}
|
||||
|
||||
export const events = {
|
||||
recordCreated,
|
||||
recordUpdated,
|
||||
recordDeleted,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { actions } from './actions'
|
||||
export { states } from './state'
|
||||
export { events } from './events'
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z, StateDefinition } from '@botpress/sdk'
|
||||
|
||||
const attioIntegrationInfo: StateDefinition = {
|
||||
type: 'integration',
|
||||
schema: z.object({
|
||||
attioWebhookId: z
|
||||
.string()
|
||||
.nullable()
|
||||
.title('Attio Webhook ID')
|
||||
.describe('ID of the webhook created in Attio for this integration'),
|
||||
}),
|
||||
}
|
||||
|
||||
export const states = {
|
||||
attioIntegrationInfo,
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# Botpress Integration: Attio CRM
|
||||
|
||||
## Overview
|
||||
|
||||
The Attio integration connects your Botpress bot to Attio’s workspace data so you can list, retrieve, create, and update records across your objects directly from their chatbot.
|
||||
|
||||
## Configuration
|
||||
|
||||
To protect your workspace, this integration uses an API token you provide. Create a token in Attio and paste it in your integration configuration.
|
||||
|
||||
### Steps
|
||||
|
||||
1. In Attio, create an API token with the following scopes:
|
||||
- `object_configuration:read` - Required to list and retrieve object definitions
|
||||
- `record:read` - Required to list and retrieve records
|
||||
- `record:write` - Required to create and update records
|
||||
2. In Botpress, add the Attio integration to your bot.
|
||||
3. Paste your API token in the integration configuration and save.
|
||||
|
||||
That's it — the integration will validate access using Attio's `/v2/self` endpoint.
|
||||
|
||||
## Notes
|
||||
|
||||
- Filtering syntax for `List Records` follows Attio’s filtering guide. Pass your filter object as provided by Attio —
|
||||
- For create/update, provide `values` using attribute slugs or IDs accepted by Attio.
|
||||
@@ -0,0 +1,24 @@
|
||||
<svg version="1.1" id="Layer_1" xmlns:x="ns_extend;" xmlns:i="ns_ai;" xmlns:graph="ns_graphs;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 60.9 50" style="enable-background:new 0 0 60.9 50;" xml:space="preserve">
|
||||
<metadata>
|
||||
<sfw xmlns="ns_sfw;">
|
||||
<slices>
|
||||
</slices>
|
||||
<sliceSourceBounds bottomLeftOrigin="true" height="50" width="60.9" x="-102.5" y="-212.1">
|
||||
</sliceSourceBounds>
|
||||
</sfw>
|
||||
</metadata>
|
||||
<g>
|
||||
<path d="M60.3,34.8l-5.1-8.1c0,0,0,0,0,0L54.7,26c-0.8-1.2-2.1-1.9-3.5-1.9L43,24L42.5,25l-9.8,15.7l-0.5,0.9l4.1,6.6
|
||||
c0.8,1.2,2.1,1.9,3.5,1.9h11.5c1.4,0,2.8-0.7,3.5-1.9l0.4-0.6c0,0,0,0,0,0l5.1-8.2C61.1,37.9,61.1,36.2,60.3,34.8L60.3,34.8z
|
||||
M58.7,38.3l-5.1,8.2c0,0,0,0.1-0.1,0.1c-0.2,0.2-0.4,0.2-0.5,0.2c-0.1,0-0.4,0-0.6-0.3l-5.1-8.2c-0.1-0.1-0.1-0.2-0.2-0.3
|
||||
c0-0.1-0.1-0.2-0.1-0.3c-0.1-0.4-0.1-0.8,0-1.3c0.1-0.2,0.1-0.4,0.3-0.6l5.1-8.1c0,0,0,0,0,0c0.1-0.2,0.3-0.3,0.4-0.3
|
||||
c0.1,0,0.1,0,0.1,0c0,0,0,0,0.1,0c0.1,0,0.4,0,0.6,0.3l5.1,8.1C59.2,36.6,59.2,37.5,58.7,38.3L58.7,38.3z">
|
||||
</path>
|
||||
<path d="M45.2,15.1c0.8-1.3,0.8-3.1,0-4.4l-5.1-8.1l-0.4-0.7C38.9,0.7,37.6,0,36.2,0H24.7c-1.4,0-2.7,0.7-3.5,1.9L0.6,34.9
|
||||
C0.2,35.5,0,36.3,0,37c0,0.8,0.2,1.5,0.6,2.2l5.5,8.8C6.9,49.3,8.2,50,9.7,50h11.5c1.4,0,2.8-0.7,3.5-1.9l0.4-0.7c0,0,0,0,0,0
|
||||
c0,0,0,0,0,0l4.1-6.6l12.1-19.4L45.2,15.1L45.2,15.1z M44,13c0,0.4-0.1,0.8-0.4,1.2L23.5,46.4c-0.2,0.3-0.5,0.3-0.6,0.3
|
||||
c-0.1,0-0.4,0-0.6-0.3l-5.1-8.2c-0.5-0.7-0.5-1.7,0-2.4L37.4,3.6c0.2-0.3,0.5-0.3,0.6-0.3c0.1,0,0.4,0,0.6,0.3l5.1,8.1
|
||||
C43.9,12.1,44,12.5,44,13z">
|
||||
</path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,27 @@
|
||||
import { IntegrationDefinition, z } from '@botpress/sdk'
|
||||
import { actions, states, events } from './definitions'
|
||||
|
||||
export default new IntegrationDefinition({
|
||||
name: 'attio',
|
||||
version: '1.0.3',
|
||||
|
||||
title: 'Attio',
|
||||
readme: 'hub.md',
|
||||
icon: 'icon.svg',
|
||||
description: 'Interact with Attio from your chatbot',
|
||||
configuration: {
|
||||
schema: z.object({
|
||||
accessToken: z.string().title('Access Token').describe('The Access token of the Attio integration'),
|
||||
}),
|
||||
},
|
||||
actions,
|
||||
states,
|
||||
events,
|
||||
__advanced: {
|
||||
useLegacyZuiTransformer: true,
|
||||
},
|
||||
attributes: {
|
||||
category: 'CRM & Sales',
|
||||
repo: 'botpress',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@botpresshub/attio",
|
||||
"scripts": {
|
||||
"check:type": "tsc --noEmit",
|
||||
"check:bplint": "bp lint",
|
||||
"build": "bp add -y && bp build",
|
||||
"test": "vitest --run"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@botpress/client": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { listObjects, getObject, listAttributes } from './objects'
|
||||
import { listRecords, getRecord, createRecord, updateRecord } from './record'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export default {
|
||||
listRecords,
|
||||
getRecord,
|
||||
createRecord,
|
||||
updateRecord,
|
||||
listObjects,
|
||||
getObject,
|
||||
listAttributes,
|
||||
} as const satisfies bp.IntegrationProps['actions']
|
||||
@@ -0,0 +1,53 @@
|
||||
//
|
||||
import { RuntimeError } from '@botpress/client'
|
||||
import { AttioApiClient } from '../attio-api'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const listObjects: bp.IntegrationProps['actions']['listObjects'] = async (props) => {
|
||||
const { ctx } = props
|
||||
const accessToken = ctx.configuration.accessToken
|
||||
|
||||
const attioApiClient = new AttioApiClient(accessToken)
|
||||
|
||||
try {
|
||||
const data = await attioApiClient.listObjects()
|
||||
return { data: data.data }
|
||||
} catch (thrown) {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new RuntimeError(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
export const getObject: bp.IntegrationProps['actions']['getObject'] = async (props) => {
|
||||
const { ctx, input } = props
|
||||
const accessToken = ctx.configuration.accessToken
|
||||
|
||||
const attioApiClient = new AttioApiClient(accessToken)
|
||||
|
||||
const { object } = input
|
||||
|
||||
try {
|
||||
const data = await attioApiClient.getObject(object)
|
||||
return { data: data.data }
|
||||
} catch (thrown) {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new RuntimeError(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
export const listAttributes: bp.IntegrationProps['actions']['listAttributes'] = async (props) => {
|
||||
const { ctx, input } = props
|
||||
const accessToken = ctx.configuration.accessToken
|
||||
|
||||
const attioApiClient = new AttioApiClient(accessToken)
|
||||
|
||||
const { object } = input
|
||||
|
||||
try {
|
||||
const data = await attioApiClient.listAttributes(object)
|
||||
return { data: data.data }
|
||||
} catch (thrown) {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new RuntimeError(error.message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//
|
||||
import { RuntimeError } from '@botpress/client'
|
||||
import { AttioApiClient, Attribute } from '../attio-api'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type AttioRecordIdentifier = {
|
||||
workspace_id: string
|
||||
object_id: string
|
||||
record_id: string
|
||||
}
|
||||
|
||||
type AttioRecord = {
|
||||
id: AttioRecordIdentifier
|
||||
created_at: string
|
||||
web_url: string
|
||||
values: Record<string, unknown>
|
||||
}
|
||||
|
||||
function _buildAttributeMaps(attributes: Attribute[]) {
|
||||
const idToSlug: Record<string, string> = {}
|
||||
const slugToOptionLabelById: Record<string, Record<string, string>> = {}
|
||||
|
||||
for (const attr of attributes) {
|
||||
// Handle new id structure (object with attribute_id) or fallback to old string id
|
||||
const id = typeof attr.id === 'object' ? attr.id?.attribute_id : String(attr.id ?? '')
|
||||
const slug = String(attr.slug ?? attr.api_slug ?? id)
|
||||
if (id) idToSlug[id] = slug
|
||||
|
||||
if (Array.isArray(attr.options) && attr.options.length > 0) {
|
||||
const map: Record<string, string> = {}
|
||||
for (const opt of attr.options) {
|
||||
const oid = String(opt.id ?? '')
|
||||
if (!oid) continue
|
||||
const label = String(opt.label ?? opt.name ?? opt.value ?? opt.title ?? opt.slug ?? oid)
|
||||
map[oid] = label
|
||||
}
|
||||
slugToOptionLabelById[slug] = map
|
||||
}
|
||||
}
|
||||
|
||||
return { idToSlug, slugToOptionLabelById }
|
||||
}
|
||||
|
||||
function _mapValueUsingOptions(value: unknown, optionMap: Record<string, string>) {
|
||||
const mapOne = (v: unknown) => {
|
||||
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
||||
const candidateId = (v as { id?: string }).id
|
||||
if (candidateId && optionMap[candidateId]) return optionMap[candidateId]
|
||||
return v
|
||||
}
|
||||
if (typeof v === 'string' && optionMap[v]) return optionMap[v]
|
||||
return v
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) return value.map((v) => mapOne(v))
|
||||
return mapOne(value)
|
||||
}
|
||||
|
||||
function _humanizeRecordValues(
|
||||
record: AttioRecord,
|
||||
idToSlug: Record<string, string>,
|
||||
slugToOptionLabelById: Record<string, Record<string, string>>
|
||||
) {
|
||||
const src: Record<string, unknown> = record?.values ?? {}
|
||||
const dst: Record<string, unknown> = {}
|
||||
|
||||
for (const key of Object.keys(src)) {
|
||||
const slug = idToSlug[key] ?? key
|
||||
const optionMap = slugToOptionLabelById[slug]
|
||||
const raw = src[key]
|
||||
const value = optionMap ? _mapValueUsingOptions(raw, optionMap) : raw
|
||||
dst[slug] = value
|
||||
}
|
||||
|
||||
return { ...record, values: dst }
|
||||
}
|
||||
|
||||
function _mapValues(values: { attribute: string; value: string }[]) {
|
||||
return values.reduce((acc: Record<string, any>, curr) => {
|
||||
acc[curr.attribute] = curr.value
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
export const listRecords: bp.IntegrationProps['actions']['listRecords'] = async (props) => {
|
||||
const { ctx, input } = props
|
||||
const accessToken = ctx.configuration.accessToken
|
||||
|
||||
const attioApiClient = new AttioApiClient(accessToken)
|
||||
|
||||
const { object, filter, sorts, limit, offset } = input
|
||||
|
||||
const filterMap = filter ? _mapValues(filter) : undefined
|
||||
|
||||
try {
|
||||
const data = await attioApiClient.listRecords(object, { filter: filterMap, sorts, limit, offset })
|
||||
const attributes = await attioApiClient.listAttributes(object)
|
||||
|
||||
const { idToSlug, slugToOptionLabelById } = _buildAttributeMaps(attributes.data)
|
||||
const humanized = data.data.map((rec) => _humanizeRecordValues(rec, idToSlug, slugToOptionLabelById))
|
||||
|
||||
return { data: humanized }
|
||||
} catch (thrown) {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new RuntimeError(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
export const getRecord: bp.IntegrationProps['actions']['getRecord'] = async (props) => {
|
||||
const { ctx, input } = props
|
||||
const accessToken = ctx.configuration.accessToken
|
||||
|
||||
const attioApiClient = new AttioApiClient(accessToken)
|
||||
|
||||
const { object, id } = input
|
||||
|
||||
try {
|
||||
const data = await attioApiClient.getRecord(object, id.record_id)
|
||||
const attributes = await attioApiClient.listAttributes(object)
|
||||
|
||||
const { idToSlug, slugToOptionLabelById } = _buildAttributeMaps(attributes.data)
|
||||
const humanized = _humanizeRecordValues(data.data, idToSlug, slugToOptionLabelById)
|
||||
|
||||
return { data: humanized }
|
||||
} catch (thrown) {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new RuntimeError(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
export const createRecord: bp.IntegrationProps['actions']['createRecord'] = async (props) => {
|
||||
const { ctx, input } = props
|
||||
const accessToken = ctx.configuration.accessToken
|
||||
|
||||
const attioApiClient = new AttioApiClient(accessToken)
|
||||
|
||||
const { object, data } = input
|
||||
const { values } = data
|
||||
|
||||
try {
|
||||
const valuesMap = _mapValues(values)
|
||||
|
||||
const data = await attioApiClient.createRecord(object, { data: { values: valuesMap } })
|
||||
return { data: data.data }
|
||||
} catch (thrown) {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new RuntimeError(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
export const updateRecord: bp.IntegrationProps['actions']['updateRecord'] = async (props) => {
|
||||
const { ctx, input } = props
|
||||
const accessToken = ctx.configuration.accessToken
|
||||
|
||||
const attioApiClient = new AttioApiClient(accessToken)
|
||||
|
||||
const { object, id, data } = input
|
||||
const { values } = data
|
||||
|
||||
try {
|
||||
const valuesMap = _mapValues(values)
|
||||
const data = await attioApiClient.updateRecord(object, id.record_id, { data: { values: valuesMap } })
|
||||
return { data: data.data }
|
||||
} catch (thrown) {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new RuntimeError(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
export const listAttributes: bp.IntegrationProps['actions']['listAttributes'] = async (props) => {
|
||||
const { ctx, input } = props
|
||||
const accessToken = ctx.configuration.accessToken
|
||||
|
||||
const attioApiClient = new AttioApiClient(accessToken)
|
||||
|
||||
const { object } = input
|
||||
|
||||
try {
|
||||
const data = await attioApiClient.listAttributes(object)
|
||||
return { data: data.data }
|
||||
} catch (thrown) {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new RuntimeError(error.message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// Types for API responses and requests
|
||||
export type RecordIdentifier = {
|
||||
workspace_id: string
|
||||
object_id: string
|
||||
record_id: string
|
||||
}
|
||||
|
||||
export type AttioRecord = {
|
||||
id: RecordIdentifier
|
||||
created_at: string
|
||||
web_url: string
|
||||
values: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type AttioObject = {
|
||||
id?: {
|
||||
workspace_id: string
|
||||
object_id: string
|
||||
}
|
||||
api_slug?: string
|
||||
singular_noun?: string
|
||||
plural_noun?: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export type Attribute = {
|
||||
id?: {
|
||||
workspace_id: string
|
||||
object_id: string
|
||||
attribute_id: string
|
||||
}
|
||||
title?: string
|
||||
description?: string | null
|
||||
api_slug?: string
|
||||
type?: string
|
||||
slug?: string
|
||||
options?: Array<{
|
||||
id?: string
|
||||
label?: string
|
||||
name?: string
|
||||
value?: string
|
||||
title?: string
|
||||
slug?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export type Sort = {
|
||||
direction: 'asc' | 'desc'
|
||||
attribute: string
|
||||
field: string
|
||||
}
|
||||
|
||||
export type ListRecordsParams = {
|
||||
filter?: Record<string, string>
|
||||
sorts?: Sort[]
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export type CreateRecordData = {
|
||||
data: {
|
||||
values: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export type UpdateRecordData = {
|
||||
data: {
|
||||
values: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export type ApiResponse<T> = {
|
||||
data: T
|
||||
}
|
||||
|
||||
export type WebhookData = {
|
||||
event_type: string
|
||||
filter: Record<string, string> | null
|
||||
}
|
||||
|
||||
export type WebhookCreateData = {
|
||||
data: {
|
||||
target_url: string
|
||||
subscriptions: WebhookData[]
|
||||
}
|
||||
}
|
||||
|
||||
export type WebhookId = {
|
||||
workspace_id: string
|
||||
webhook_id: string
|
||||
}
|
||||
|
||||
export type WebhookResponse = {
|
||||
id: WebhookId
|
||||
target_url: string
|
||||
subscriptions: WebhookData[]
|
||||
created_at: string
|
||||
secret: string
|
||||
}
|
||||
|
||||
export type ListRecordsResponse = ApiResponse<AttioRecord[]>
|
||||
export type GetRecordResponse = ApiResponse<AttioRecord>
|
||||
export type CreateRecordResponse = ApiResponse<AttioRecord>
|
||||
export type UpdateRecordResponse = ApiResponse<AttioRecord>
|
||||
export type ListObjectsResponse = ApiResponse<AttioObject[]>
|
||||
export type GetObjectResponse = ApiResponse<AttioObject>
|
||||
export type ListAttributesResponse = ApiResponse<Attribute[]>
|
||||
|
||||
export type CreateWebhookResponse = ApiResponse<WebhookResponse>
|
||||
|
||||
export class AttioApiClient {
|
||||
private _accessToken: string
|
||||
private _baseUrl: string = 'https://api.attio.com/v2'
|
||||
|
||||
public constructor(accessToken: string) {
|
||||
this._accessToken = accessToken
|
||||
}
|
||||
|
||||
private async _makeRequest<T>(
|
||||
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
||||
endpoint: string,
|
||||
body?: unknown
|
||||
): Promise<T> {
|
||||
const url = `${this._baseUrl}${endpoint}`
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${this._accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
const config: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
}
|
||||
|
||||
if (body && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
|
||||
config.body = JSON.stringify(body)
|
||||
}
|
||||
|
||||
const response = await fetch(url, config)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Attio API Error: ${response.status} ${response.statusText} - ${errorText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data
|
||||
}
|
||||
|
||||
// Records API methods
|
||||
public async listRecords(object: string, params?: ListRecordsParams): Promise<ListRecordsResponse> {
|
||||
const endpoint = `/objects/${object}/records/query`
|
||||
|
||||
const body = {
|
||||
filter: params?.filter,
|
||||
sorts: params?.sorts,
|
||||
limit: params?.limit,
|
||||
offset: params?.offset,
|
||||
}
|
||||
|
||||
return this._makeRequest<ListRecordsResponse>('POST', endpoint, body)
|
||||
}
|
||||
|
||||
public async getRecord(object: string, recordId: string): Promise<GetRecordResponse> {
|
||||
const endpoint = `/objects/${object}/records/${recordId}`
|
||||
return this._makeRequest<GetRecordResponse>('GET', endpoint)
|
||||
}
|
||||
|
||||
public async createRecord(object: string, data: CreateRecordData): Promise<CreateRecordResponse> {
|
||||
const endpoint = `/objects/${object}/records`
|
||||
return this._makeRequest<CreateRecordResponse>('POST', endpoint, data)
|
||||
}
|
||||
|
||||
public async updateRecord(object: string, recordId: string, data: UpdateRecordData): Promise<UpdateRecordResponse> {
|
||||
const endpoint = `/objects/${object}/records/${recordId}`
|
||||
return this._makeRequest<UpdateRecordResponse>('PUT', endpoint, data)
|
||||
}
|
||||
|
||||
// Objects API methods
|
||||
public async listObjects(): Promise<ListObjectsResponse> {
|
||||
const endpoint = '/objects'
|
||||
return this._makeRequest<ListObjectsResponse>('GET', endpoint)
|
||||
}
|
||||
|
||||
public async getObject(object: string): Promise<GetObjectResponse> {
|
||||
const endpoint = `/objects/${object}`
|
||||
return this._makeRequest<GetObjectResponse>('GET', endpoint)
|
||||
}
|
||||
|
||||
// Attributes API methods
|
||||
public async listAttributes(object: string): Promise<ListAttributesResponse> {
|
||||
const endpoint = `/objects/${object}/attributes`
|
||||
return this._makeRequest<ListAttributesResponse>('GET', endpoint)
|
||||
}
|
||||
|
||||
// Webhook API methods
|
||||
public async createWebhook(data: WebhookCreateData): Promise<CreateWebhookResponse> {
|
||||
const endpoint = '/webhooks'
|
||||
return this._makeRequest<CreateWebhookResponse>('POST', endpoint, data)
|
||||
}
|
||||
|
||||
public async deleteWebhook(webhookId: string): Promise<void> {
|
||||
const endpoint = `/webhooks/${webhookId}`
|
||||
await this._makeRequest<void>('DELETE', endpoint)
|
||||
}
|
||||
|
||||
// Utility method to test connection
|
||||
public async testConnection(): Promise<void> {
|
||||
await this._makeRequest<{ data: unknown }>('GET', '/self')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
import { recordEventSchema } from 'src/schemas'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const recordCreated = async ({
|
||||
payload,
|
||||
client,
|
||||
logger,
|
||||
}: {
|
||||
payload: z.infer<typeof recordEventSchema>
|
||||
client: bp.Client
|
||||
logger: bp.Logger
|
||||
}) => {
|
||||
await client.createEvent({
|
||||
type: 'recordCreated',
|
||||
payload: {
|
||||
...payload,
|
||||
event_type: 'record.created',
|
||||
},
|
||||
})
|
||||
|
||||
logger.forBot().info(`Record created: ${payload.id.record_id}`)
|
||||
}
|
||||
|
||||
export const recordUpdated = async ({
|
||||
payload,
|
||||
client,
|
||||
logger,
|
||||
}: {
|
||||
payload: z.infer<typeof recordEventSchema>
|
||||
client: bp.Client
|
||||
logger: bp.Logger
|
||||
}) => {
|
||||
await client.createEvent({
|
||||
type: 'recordUpdated',
|
||||
payload: {
|
||||
...payload,
|
||||
event_type: 'record.updated',
|
||||
},
|
||||
})
|
||||
|
||||
logger.forBot().info(`Record updated: ${payload.id.record_id}`)
|
||||
}
|
||||
|
||||
export const recordDeleted = async ({
|
||||
payload,
|
||||
client,
|
||||
logger,
|
||||
}: {
|
||||
payload: z.infer<typeof recordEventSchema>
|
||||
client: bp.Client
|
||||
logger: bp.Logger
|
||||
}) => {
|
||||
await client.createEvent({
|
||||
type: 'recordDeleted',
|
||||
payload: {
|
||||
...payload,
|
||||
event_type: 'record.deleted',
|
||||
},
|
||||
})
|
||||
|
||||
logger.forBot().info(`Record deleted: ${payload.id.record_id}`)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import actions from './actions'
|
||||
import { register, unregister, handler } from './setup'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export default new bp.Integration({
|
||||
register,
|
||||
unregister,
|
||||
actions,
|
||||
channels: {},
|
||||
handler,
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
import { baseIdentifierSchema } from '../definitions/common'
|
||||
|
||||
export const recordEventSchema = z.object({
|
||||
event_type: z.string(),
|
||||
id: baseIdentifierSchema.extend({ record_id: z.string() }),
|
||||
actor: z.object({
|
||||
type: z.string(),
|
||||
id: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
export const webhookPayloadSchema = z.object({
|
||||
webhook_id: z.string(),
|
||||
events: z.array(recordEventSchema),
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { webhookPayloadSchema } from 'src/schemas'
|
||||
import { recordCreated, recordUpdated, recordDeleted } from '../events'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export function safeJsonParse(x: any) {
|
||||
try {
|
||||
return { data: JSON.parse(x), success: true }
|
||||
} catch {
|
||||
return { data: x, success: false }
|
||||
}
|
||||
}
|
||||
|
||||
export const handler: bp.IntegrationProps['handler'] = async ({ req, logger, client }) => {
|
||||
if (!req.body) {
|
||||
const errorMsg = 'Webhook processing failed: empty body'
|
||||
logger.forBot().error(errorMsg)
|
||||
return {
|
||||
status: 400,
|
||||
body: JSON.stringify({ error: errorMsg }),
|
||||
}
|
||||
}
|
||||
|
||||
// Parse and validate the webhook payload
|
||||
const webhookData = safeJsonParse(req.body)
|
||||
if (!webhookData.success) {
|
||||
const errorMsg = 'Webhook processing failed: invalid JSON'
|
||||
logger.forBot().error(errorMsg)
|
||||
return { status: 400, body: JSON.stringify({ error: errorMsg }) }
|
||||
}
|
||||
|
||||
const parseResult = webhookPayloadSchema.safeParse(webhookData.data)
|
||||
if (!parseResult.success) {
|
||||
const errorMsg = `Webhook processing failed: ${parseResult.error.message}`
|
||||
logger.forBot().error(errorMsg)
|
||||
return { status: 400, body: JSON.stringify({ error: errorMsg }) }
|
||||
}
|
||||
|
||||
const { webhook_id, events } = parseResult.data
|
||||
logger.forBot().info(`Processing webhook ${webhook_id} with ${events.length} events`)
|
||||
|
||||
// Process each event
|
||||
for (const attioEvent of events) {
|
||||
const event = attioEvent.event_type
|
||||
|
||||
switch (event) {
|
||||
case 'record.created':
|
||||
await recordCreated({ payload: attioEvent, client, logger })
|
||||
break
|
||||
case 'record.updated':
|
||||
await recordUpdated({ payload: attioEvent, client, logger })
|
||||
break
|
||||
case 'record.deleted':
|
||||
await recordDeleted({ payload: attioEvent, client, logger })
|
||||
break
|
||||
default:
|
||||
logger.forBot().warn(`Unsupported event type: ${event}`)
|
||||
}
|
||||
}
|
||||
|
||||
return { status: 200 }
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { register, unregister } from './setup'
|
||||
export { handler } from './handler'
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import { AttioApiClient } from '../attio-api'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const register: bp.IntegrationProps['register'] = async ({ ctx, client, webhookUrl, logger }) => {
|
||||
try {
|
||||
const accessToken = ctx.configuration.accessToken
|
||||
const attioClient = new AttioApiClient(accessToken)
|
||||
|
||||
// Test the connection using the API client
|
||||
logger.forBot().info('Testing connection to Attio...')
|
||||
await attioClient.testConnection()
|
||||
logger.forBot().info('Connection to Attio successful')
|
||||
|
||||
// Check if webhooks already exist
|
||||
logger.forBot().info('Checking if webhooks already exist...')
|
||||
const { state } = await client.getOrSetState({
|
||||
name: 'attioIntegrationInfo',
|
||||
type: 'integration',
|
||||
id: ctx.integrationId,
|
||||
payload: {
|
||||
attioWebhookId: '',
|
||||
},
|
||||
})
|
||||
|
||||
if (!state.payload.attioWebhookId) {
|
||||
logger.forBot().info('Webhooks do not exist. Creating webhooks...')
|
||||
const webhookResp = await attioClient.createWebhook({
|
||||
data: {
|
||||
target_url: webhookUrl,
|
||||
subscriptions: [
|
||||
{ event_type: 'record.created', filter: null },
|
||||
{ event_type: 'record.updated', filter: null },
|
||||
{ event_type: 'record.deleted', filter: null },
|
||||
],
|
||||
},
|
||||
})
|
||||
const attioWebhookId = String(webhookResp.data.id.webhook_id)
|
||||
await client.setState({
|
||||
type: 'integration',
|
||||
name: 'attioIntegrationInfo',
|
||||
id: ctx.integrationId,
|
||||
payload: { attioWebhookId },
|
||||
})
|
||||
}
|
||||
|
||||
logger.forBot().info('Webhooks created')
|
||||
} catch (thrown) {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new sdk.RuntimeError(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
export const unregister: bp.IntegrationProps['unregister'] = async ({ ctx, client, logger }) => {
|
||||
try {
|
||||
const accessToken = ctx.configuration.accessToken
|
||||
const attioClient = new AttioApiClient(accessToken)
|
||||
|
||||
const stateAttioIntegrationInfo = await client.getState({
|
||||
id: ctx.integrationId,
|
||||
name: 'attioIntegrationInfo',
|
||||
type: 'integration',
|
||||
})
|
||||
|
||||
const { state } = stateAttioIntegrationInfo
|
||||
const { attioWebhookId } = state.payload
|
||||
|
||||
if (attioWebhookId) {
|
||||
await attioClient.deleteWebhook(attioWebhookId)
|
||||
logger.forBot().info('Webhook successfully deleted')
|
||||
}
|
||||
} catch (thrown) {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new sdk.RuntimeError(error.message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import config from '../../vitest.config'
|
||||
export default config
|
||||
@@ -0,0 +1,91 @@
|
||||
import { z, type ActionDefinition } from '@botpress/sdk'
|
||||
import {
|
||||
bambooHrCompanyInfo,
|
||||
bambooHrEmployeeBasicInfoResponse,
|
||||
bambooHrEmployeeCustomInfoResponse,
|
||||
bambooHrFields,
|
||||
} from './bamboohr-schemas'
|
||||
import { employeeId, employeeIdObject } from './common'
|
||||
|
||||
const getEmployeeBasicInfo: ActionDefinition = {
|
||||
title: 'Get Basic Info',
|
||||
description: 'Retrieve basic information about an employee by their ID.',
|
||||
input: {
|
||||
schema: employeeIdObject,
|
||||
},
|
||||
output: {
|
||||
schema: bambooHrEmployeeBasicInfoResponse,
|
||||
},
|
||||
}
|
||||
|
||||
const getEmployeeCustomInfo: ActionDefinition = {
|
||||
title: 'Get Custom Info',
|
||||
description: 'Retrieve custom information about an employee by their ID and specified fields.',
|
||||
input: {
|
||||
schema: z.object({
|
||||
id: employeeId,
|
||||
fields: z
|
||||
.array(z.string().min(1))
|
||||
.min(1)
|
||||
.title('Custom Fields')
|
||||
.describe('List of custom field names to retrieve for the employee.'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: bambooHrEmployeeCustomInfoResponse,
|
||||
},
|
||||
}
|
||||
|
||||
const listEmployees: ActionDefinition = {
|
||||
title: 'List Employees',
|
||||
description:
|
||||
'Retrieve a list of all employees with IDs and names. Warning: this endpoint may not be enabled in your workspace.',
|
||||
input: {
|
||||
schema: z.object({}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
employees: z
|
||||
.array(
|
||||
z.object({
|
||||
id: employeeId,
|
||||
displayName: z.string().title('Display Name').describe("Employee's display name."),
|
||||
})
|
||||
)
|
||||
.title('Employees')
|
||||
.describe('List of employees in the directory. Includes only ID and display name.'),
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
const getCompanyInfo: ActionDefinition = {
|
||||
title: 'Get Company Info',
|
||||
description: 'Retrieve information about the company.',
|
||||
input: {
|
||||
schema: z.object({}),
|
||||
},
|
||||
output: {
|
||||
schema: bambooHrCompanyInfo,
|
||||
},
|
||||
}
|
||||
|
||||
const getFields: ActionDefinition = {
|
||||
title: 'Get Employee Fields',
|
||||
description: 'Retrieve the list of available employee fields.',
|
||||
input: {
|
||||
schema: z.object({}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
fields: bambooHrFields,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
getEmployeeBasicInfo,
|
||||
getEmployeeCustomInfo,
|
||||
listEmployees,
|
||||
getCompanyInfo,
|
||||
getFields,
|
||||
} as const
|
||||
@@ -0,0 +1,227 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
import mapValues from 'lodash/mapValues'
|
||||
import { employeeId, timestamp } from './common'
|
||||
|
||||
export const bambooHrOauthTokenResponse = z.object({
|
||||
access_token: z.string().title('Access Token').describe('The temporary access token issued by BambooHR.'),
|
||||
refresh_token: z.string().title('Refresh Token').describe('The refresh token issued by BambooHR.'),
|
||||
expires_in: z.number().title('Expires In').describe('The number of seconds until the access token expires.'),
|
||||
token_type: z.literal('Bearer').title('Token Type').describe('The type of token issued.'),
|
||||
scope: z.string().title('Scopes').describe('The scopes granted to the access token (space-separated).'),
|
||||
id_token: z.string().title('ID Token').describe('A unique identifier for the user.'),
|
||||
companyDomain: z.string().title('Company Domain').describe('The company subdomain on BambooHR.'),
|
||||
})
|
||||
|
||||
export const bambooHrWebhookCreateResponse = z.object({
|
||||
id: z.string().title('Webhook ID').describe('The unique identifier for the created webhook.'),
|
||||
privateKey: z.string().title('Private Key').describe('The private key to validate incoming webhooks.'),
|
||||
})
|
||||
|
||||
/** Fields that can be monitored for updates as a webhook event */
|
||||
export const bambooHrEmployeeWebhookFields = z
|
||||
.object({
|
||||
customBenefitIDNumber: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.title('Custom Benefit ID Number')
|
||||
.describe('Custom benefit ID number.'),
|
||||
customCitizenshipCertificate: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.title('Custom Citizenship Certificate')
|
||||
.describe('Custom citizenship certificate.'),
|
||||
payChangeReason: z.string().nullable().optional().title('Pay Change Reason').describe('Pay change reason.'),
|
||||
payRateEffectiveDate: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.title('Pay Rate Effective Date')
|
||||
.describe('Pay rate effective date.'),
|
||||
department: z.string().nullable().optional().title('Department').describe('Department.'),
|
||||
division: z.string().nullable().optional().title('Division').describe('Division.'),
|
||||
employeeNumber: z.string().nullable().optional().title('Employee Number').describe('Employee number.'),
|
||||
employeeTaxType: z.string().nullable().optional().title('Employee Tax Type').describe('Employee tax type.'),
|
||||
employmentHistoryStatus: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.title('Employment History Status')
|
||||
.describe('Employment history status.'),
|
||||
employeeStatusDate: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.title('Employee Status Date')
|
||||
.describe('Employee status date.'),
|
||||
ethnicity: z.string().nullable().optional().title('Ethnicity').describe('Ethnicity.'),
|
||||
facebook: z.string().nullable().optional().title('Facebook').describe('Facebook.'),
|
||||
firstName: z.string().nullable().optional().title('First Name').describe('First name.'),
|
||||
gender: z.string().nullable().optional().title('Gender').describe('Gender.'),
|
||||
hireDate: z.string().nullable().optional().title('Hire Date').describe('Hire date.'),
|
||||
homeEmail: z.string().nullable().optional().title('Home Email').describe('Home email.'),
|
||||
homePhone: z.string().nullable().optional().title('Home Phone').describe('Home phone.'),
|
||||
jobTitle: z.string().nullable().optional().title('Job Title').describe('Job title.'),
|
||||
lastName: z.string().nullable().optional().title('Last Name').describe('Last name.'),
|
||||
linkedIn: z.string().nullable().optional().title('LinkedIn').describe('LinkedIn.'),
|
||||
location: z.string().nullable().optional().title('Location').describe('Location.'),
|
||||
maritalStatus: z.string().nullable().optional().title('Marital Status').describe('Marital status.'),
|
||||
middleName: z.string().nullable().optional().title('Middle Name').describe('Middle name.'),
|
||||
mobilePhone: z.string().nullable().optional().title('Mobile Phone').describe('Mobile phone.'),
|
||||
customNIN1: z.string().nullable().optional().title('Custom NIN1').describe('Custom NIN1.'),
|
||||
nationality: z.string().nullable().optional().title('Nationality').describe('Nationality.'),
|
||||
originalHireDate: z.string().nullable().optional().title('Original Hire Date').describe('Original hire date.'),
|
||||
overtimeRate: z.string().nullable().optional().title('Overtime Rate').describe('Overtime rate.'),
|
||||
exempt: z.string().nullable().optional().title('Exempt').describe('Exempt.'),
|
||||
payPer: z.string().nullable().optional().title('Pay Per').describe('Pay per.'),
|
||||
paySchedule: z.string().nullable().optional().title('Pay Schedule').describe('Pay schedule.'),
|
||||
payRate: z.string().nullable().optional().title('Pay Rate').describe('Pay rate.'),
|
||||
payType: z.string().nullable().optional().title('Pay Type').describe('Pay type.'),
|
||||
preferredName: z.string().nullable().optional().title('Preferred Name').describe('Preferred name.'),
|
||||
customProbationaryPeriodEndDate: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.title('Custom Probationary Period End Date')
|
||||
.describe('Custom probationary period end date.'),
|
||||
customProbationaryPeriodStartDate: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.title('Custom Probationary Period Start Date')
|
||||
.describe('Custom probationary period start date.'),
|
||||
customProjectedTerminationDate: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.title('Custom Projected Termination Date')
|
||||
.describe('Custom projected termination date.'),
|
||||
customROESATCompleted: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.title('Custom ROESAT Completed')
|
||||
.describe('Custom ROESAT completed.'),
|
||||
reportsTo: z.string().nullable().optional().title('Reports To').describe('Reports to.'),
|
||||
customShirtsize: z.string().nullable().optional().title('Custom Shirt Size').describe('Custom shirt size.'),
|
||||
status: z.string().nullable().optional().title('Status').describe('Status.'),
|
||||
teams: z.string().nullable().optional().title('Teams').describe('Teams.'),
|
||||
customTerminationCode: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.title('Custom Termination Code')
|
||||
.describe('Custom termination code.'),
|
||||
customTerminationNoticeGiven: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.title('Custom Termination Notice Given')
|
||||
.describe('Custom termination notice given.'),
|
||||
twitterFeed: z.string().nullable().optional().title('Twitter Feed').describe('Twitter feed.'),
|
||||
workEmail: z.string().nullable().optional().title('Work Email').describe('Work email.'),
|
||||
workPhoneExtension: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.title('Work Phone Extension')
|
||||
.describe('Work phone extension.'),
|
||||
workPhone: z.string().nullable().optional().title('Work Phone').describe('Work phone.'),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const bambooHrEmployeeBaseEvent = z.object({
|
||||
id: employeeId,
|
||||
timestamp,
|
||||
})
|
||||
export const bambooHrEmployeeCreatedEvent = bambooHrEmployeeBaseEvent.extend({
|
||||
action: z.literal('Created').title('Action').describe('Creation action.'),
|
||||
})
|
||||
export const bambooHrEmployeeDeletedEvent = bambooHrEmployeeBaseEvent.extend({
|
||||
action: z.literal('Deleted').title('Action').describe('Deletion action.'),
|
||||
})
|
||||
export const bambooHrEmployeeUpdatedEvent = bambooHrEmployeeBaseEvent.extend({
|
||||
action: z.literal('Updated').title('Action').describe('Update action.'),
|
||||
fields: z
|
||||
.object(mapValues(bambooHrEmployeeWebhookFields.shape, (schema) => z.object({ value: schema })))
|
||||
.title('Fields')
|
||||
.describe(
|
||||
'All fields subscribed to for the employee, including unchanged fields. Value is nested as `fieldName: { value }`.'
|
||||
),
|
||||
changedFields: z
|
||||
.array(z.string())
|
||||
.title('Changed Fields')
|
||||
.describe('List of fields that were changed in this event.'),
|
||||
})
|
||||
|
||||
/**Employee events by BambooHR webhook */
|
||||
export const bambooHrEmployeeWebhookEvent = z.object({
|
||||
employees: z
|
||||
.array(z.union([bambooHrEmployeeCreatedEvent, bambooHrEmployeeDeletedEvent, bambooHrEmployeeUpdatedEvent]))
|
||||
.title('Employees')
|
||||
.describe('List of employees included in the event.'),
|
||||
})
|
||||
|
||||
/** All fields that can be queried on an employee as an action */
|
||||
export const bambooHrEmployeeBasicInfoResponse = bambooHrEmployeeWebhookFields.extend({
|
||||
id: employeeId,
|
||||
supervisorEid: z.string().nullable().optional().title('Supervisor ID').describe("Employee's supervisor's ID."),
|
||||
lastChanged: z.string().title('Last Changed').describe('Full timestamp of the last change to the employee record.'),
|
||||
displayName: z.string().title('Display Name').describe("Employee's display name."),
|
||||
})
|
||||
|
||||
export const bambooHrEmployeeCustomInfoResponse = z.object({ id: employeeId }).passthrough()
|
||||
|
||||
export const bambooHrEmployeeDirectoryResponse = z.object({
|
||||
fields: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().title('Field ID').describe('Unique identifier for the field.'),
|
||||
name: z.string().title('Field Name').describe('Name of the field.'),
|
||||
type: z.string().title('Field Type').describe('Type of the field. See BambooHR API documentation for details.'),
|
||||
})
|
||||
)
|
||||
.title('Fields')
|
||||
.describe('List of fields included in the directory. Does not include all workspace fields.'),
|
||||
employees: z
|
||||
.array(
|
||||
z.object({
|
||||
id: employeeId,
|
||||
displayName: z.string().title('Display Name').describe("Employee's display name."),
|
||||
})
|
||||
)
|
||||
.title('Employees')
|
||||
.describe('List of employees in the directory. Includes more fields.'),
|
||||
})
|
||||
|
||||
export const bambooHrCompanyInfo = z.object({
|
||||
legalName: z.string().title('Legal Name').describe('Legal name of the company.'),
|
||||
displayName: z.string().title('Display Name').describe('Display name of the company.'),
|
||||
address: z
|
||||
.object({
|
||||
line1: z.string().nullable().optional().title('Address Line 1').describe('First line of the street.'),
|
||||
line2: z.string().nullable().optional().title('Address Line 2').describe('Second line of the street.'),
|
||||
city: z.string().nullable().optional().title('City').describe('City.'),
|
||||
state: z.string().nullable().optional().title('State').describe('State or province.'),
|
||||
country: z.string().nullable().optional().title('Country').describe('Country.'),
|
||||
zip: z.string().nullable().optional().title('ZIP Code').describe('ZIP code.'),
|
||||
})
|
||||
.title('Address')
|
||||
.describe('Address of the company.'),
|
||||
phone: z.string().nullable().optional().title('Phone Number').describe('Phone number of the company.'),
|
||||
})
|
||||
|
||||
export const bambooHrFields = z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z.union([z.string(), z.number()]).title('ID').describe('Unique identifier for the field.'),
|
||||
name: z.string().title('Name').describe('Name of the field.'),
|
||||
type: z.string().title('Type').describe('Data type of the field.'),
|
||||
alias: z.string().optional().title('Alias').describe('Alias used for the field in API requests.'),
|
||||
})
|
||||
.passthrough()
|
||||
)
|
||||
.title('Fields')
|
||||
.describe('List of available employee fields.')
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
|
||||
/** User-friendly pastable url, to be extracted to subdomain for use in handler */
|
||||
export const subdomain = z
|
||||
.string()
|
||||
.min(1)
|
||||
.title('BambooHR Subdomain')
|
||||
.describe("Your company's BambooHR subdomain (https://[subdomain].bamboohr.com)")
|
||||
|
||||
export const employeeId = z.string().min(1).title('Employee ID').describe('Unique identifier for the employee.')
|
||||
export const employeeIdObject = z.object({ id: employeeId })
|
||||
|
||||
export const timestamp = z.string().title('Timestamp').describe('Timestamp in ISO 8601 format.')
|
||||
@@ -0,0 +1,41 @@
|
||||
import { z, type EventDefinition } from '@botpress/sdk'
|
||||
import { bambooHrEmployeeWebhookFields } from './bamboohr-schemas'
|
||||
import { employeeId, timestamp } from './common'
|
||||
|
||||
const sharedEventFields = z.object({
|
||||
id: employeeId,
|
||||
timestamp,
|
||||
})
|
||||
|
||||
const employeeCreated: EventDefinition = {
|
||||
title: 'Employee Created',
|
||||
description: 'Triggers when a new employee is created in BambooHR.',
|
||||
schema: sharedEventFields,
|
||||
}
|
||||
|
||||
const employeeDeleted: EventDefinition = {
|
||||
title: 'Employee Deleted',
|
||||
description:
|
||||
'Triggers when an employee is deleted from BambooHR. Terminating employees will trigger the "Employee Updated" event instead.',
|
||||
schema: sharedEventFields,
|
||||
}
|
||||
|
||||
const employeeUpdated: EventDefinition = {
|
||||
title: 'Employee Updated',
|
||||
description: 'Triggers when an existing employee is updated in BambooHR.',
|
||||
schema: sharedEventFields.extend({
|
||||
fields: bambooHrEmployeeWebhookFields
|
||||
.title('Fields')
|
||||
.describe('All fields subscribed to for the employee, including unchanged fields.'),
|
||||
changedFields: z
|
||||
.array(z.string())
|
||||
.title('Changed Fields')
|
||||
.describe('List of fields that were changed in this event.'),
|
||||
}),
|
||||
}
|
||||
|
||||
export const events = {
|
||||
employeeCreated,
|
||||
employeeDeleted,
|
||||
employeeUpdated,
|
||||
} as const
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './bamboohr-schemas'
|
||||
export * from './common'
|
||||
export { actions } from './actions'
|
||||
export { events } from './events'
|
||||
@@ -0,0 +1,7 @@
|
||||
# BambooHR Integration
|
||||
|
||||
The BambooHR integration connects your Botpress agent with your company’s HR system, allowing it to access and manage employee data. It enables an agent to read and update employee information, fetch organizational details, and interact with key BambooHR resources like job titles, departments, and time-off balances.
|
||||
|
||||
With this integration, your agent can automate common HR tasks like looking up employee profiles, verifying job information, checking leave requests, or returning structured HR data to other connected systems. It provides endpoints for managing records, performing authenticated API calls, and handling responses in a consistent format.
|
||||
|
||||
By bridging BambooHR and Botpress, your agent can directly participate in HR workflows by helping you find information faster and reducing manual data entry.
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.6 KiB |
@@ -0,0 +1,83 @@
|
||||
import { posthogHelper } from '@botpress/common'
|
||||
import { IntegrationDefinition, z } from '@botpress/sdk'
|
||||
import { actions, events, subdomain } from './definitions'
|
||||
|
||||
export const INTEGRATION_NAME = 'bamboohr'
|
||||
export const INTEGRATION_VERSION = '2.1.3'
|
||||
|
||||
export default new IntegrationDefinition({
|
||||
name: INTEGRATION_NAME,
|
||||
version: INTEGRATION_VERSION,
|
||||
title: 'BambooHR',
|
||||
description: 'Retrieve your BambooHR information',
|
||||
readme: 'hub.md',
|
||||
icon: 'icon.svg',
|
||||
|
||||
configuration: {
|
||||
identifier: {
|
||||
linkTemplateScript: 'linkTemplate.vrl',
|
||||
required: true,
|
||||
},
|
||||
schema: z.object({}),
|
||||
},
|
||||
|
||||
configurations: {
|
||||
manual: {
|
||||
title: 'Manual configuration',
|
||||
description: 'Configure manually with your BambooHR API Key',
|
||||
schema: z.object({
|
||||
apiKey: z.string().min(1).title('API Key').describe('Your BambooHR API Key, from My Account > Api Keys'),
|
||||
subdomain,
|
||||
}),
|
||||
},
|
||||
},
|
||||
actions,
|
||||
events,
|
||||
secrets: {
|
||||
OAUTH_CLIENT_SECRET: {
|
||||
description: 'The OAuth Client Secret provided by BambooHR from the developer portal.',
|
||||
},
|
||||
OAUTH_CLIENT_ID: {
|
||||
description: 'The OAuth Client ID provided by BambooHR from the developer portal.',
|
||||
},
|
||||
...posthogHelper.COMMON_SECRET_NAMES,
|
||||
},
|
||||
states: {
|
||||
oauth: {
|
||||
type: 'integration',
|
||||
schema: z
|
||||
.object({
|
||||
domain: z.string().title('Domain').describe('The domain of the company.'),
|
||||
accessToken: z.string().title('Temporary Access Token').describe('Temporary access token for the API.'),
|
||||
refreshToken: z.string().title('Refresh Token').describe('Token used to refresh the access token.'),
|
||||
expiresAt: z
|
||||
.number()
|
||||
.title('Expiration Timestamp')
|
||||
.describe('Timestamp (in ms from epoch) at which the token expires.'),
|
||||
scopes: z.string().title('Scopes').describe('Scopes for the token (space-separated).'),
|
||||
})
|
||||
.title('OAuth Parameters')
|
||||
.describe('Parameters required to authenticate via OAuth. Not required if using API Key.'),
|
||||
},
|
||||
webhook: {
|
||||
type: 'integration',
|
||||
schema: z.object({
|
||||
privateKey: z
|
||||
.string()
|
||||
.nullable()
|
||||
.title('Private Key')
|
||||
.describe('The private key provided by BambooHR to validate the webhook.'),
|
||||
id: z
|
||||
.string()
|
||||
.nullable()
|
||||
.title('Webhook ID')
|
||||
.describe('The ID of the webhook as provided by BambooHR when the webhook was created.'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
category: 'Business Operations',
|
||||
guideSlug: 'bamboohr',
|
||||
repo: 'botpress',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
webhookId = to_string!(.webhookId)
|
||||
webhookUrl = to_string!(.webhookUrl)
|
||||
|
||||
"{{ webhookUrl }}/oauth/wizard/start?state={{ webhookId }}"
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@botpresshub/bamboohr",
|
||||
"scripts": {
|
||||
"build": "bp add -y && bp build",
|
||||
"check:type": "tsc --noEmit",
|
||||
"check:bplint": "bp lint",
|
||||
"test": "vitest --run"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@botpress/client": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/common": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"@types/jsonwebtoken": "^9.0.3",
|
||||
"@types/lodash": "^4.14.191"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { BambooHRClient } from 'src/api/bamboohr-client'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const getCompanyInfo: bp.IntegrationProps['actions']['getCompanyInfo'] = async ({ client, ctx, logger }) => {
|
||||
const bambooHrClient = await BambooHRClient.create({ client, ctx, logger })
|
||||
try {
|
||||
return await bambooHrClient.getCompanyInfo()
|
||||
} catch (thrown) {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new RuntimeError(`Failed to get company info: ${error.message}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { BambooHRClient } from 'src/api/bamboohr-client'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const getEmployeeBasicInfo: bp.IntegrationProps['actions']['getEmployeeBasicInfo'] = async ({
|
||||
client,
|
||||
ctx,
|
||||
logger,
|
||||
input,
|
||||
}) => {
|
||||
const bambooHrClient = await BambooHRClient.create({ client, ctx, logger })
|
||||
|
||||
try {
|
||||
return await bambooHrClient.getEmployeeBasicInfo(input.id)
|
||||
} catch (thrown) {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new RuntimeError(`Failed to get employee basic info: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const getEmployeeCustomInfo: bp.IntegrationProps['actions']['getEmployeeCustomInfo'] = async ({
|
||||
input,
|
||||
client,
|
||||
ctx,
|
||||
logger,
|
||||
}) => {
|
||||
const bambooHrClient = await BambooHRClient.create({ client, ctx, logger })
|
||||
|
||||
try {
|
||||
return await bambooHrClient.getEmployeeCustomInfo(input.id, input.fields)
|
||||
} catch (thrown) {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new RuntimeError(`Failed to get employee custom info: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const listEmployees: bp.IntegrationProps['actions']['listEmployees'] = async ({ client, ctx, logger }) => {
|
||||
const bambooHrClient = await BambooHRClient.create({ client, ctx, logger })
|
||||
|
||||
try {
|
||||
return await bambooHrClient.listEmployees()
|
||||
} catch (thrown) {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new RuntimeError(`Failed to list employees: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const getFields: bp.IntegrationProps['actions']['getFields'] = async ({ client, ctx, logger }) => {
|
||||
const bambooHrClient = await BambooHRClient.create({ client, ctx, logger })
|
||||
|
||||
try {
|
||||
return { fields: await bambooHrClient.getFields() }
|
||||
} catch (thrown) {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new RuntimeError(`Failed to get fields: ${error.message}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { getCompanyInfo } from './company'
|
||||
import { getEmployeeBasicInfo, getEmployeeCustomInfo, listEmployees, getFields } from './employees'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const actions = {
|
||||
getEmployeeBasicInfo,
|
||||
getEmployeeCustomInfo,
|
||||
listEmployees,
|
||||
getCompanyInfo,
|
||||
getFields,
|
||||
} satisfies bp.IntegrationProps['actions']
|
||||
@@ -0,0 +1,176 @@
|
||||
import { bambooHrOauthTokenResponse } from 'definitions'
|
||||
import * as types from '../types'
|
||||
import { stripSubdomain } from './utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const OAUTH_EXPIRATION_MARGIN = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
const _fetchBambooHrOauthToken = async (props: {
|
||||
subdomain: string
|
||||
oAuthInfo: { code: string; redirectUri: string } | { refreshToken: string }
|
||||
}): Promise<{
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiresAt: number
|
||||
scopes: string
|
||||
idToken: string
|
||||
}> => {
|
||||
const { subdomain, oAuthInfo } = props
|
||||
const bambooHrOauthUrl = `https://${subdomain}.bamboohr.com/token.php?request=token`
|
||||
|
||||
const { OAUTH_CLIENT_SECRET, OAUTH_CLIENT_ID } = bp.secrets
|
||||
|
||||
// See https://documentation.bamboohr.com/docs/getting-started
|
||||
const requestTimestamp = Date.now()
|
||||
|
||||
const body = JSON.stringify({
|
||||
client_id: OAUTH_CLIENT_ID,
|
||||
client_secret: OAUTH_CLIENT_SECRET,
|
||||
...('code' in oAuthInfo
|
||||
? { grant_type: 'authorization_code', code: oAuthInfo.code, redirect_uri: oAuthInfo.redirectUri }
|
||||
: { grant_type: 'refresh_token', refresh_token: oAuthInfo.refreshToken }),
|
||||
})
|
||||
|
||||
const tokenResponse = await fetch(bambooHrOauthUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body,
|
||||
})
|
||||
|
||||
if (tokenResponse.status < 200 || tokenResponse.status >= 300) {
|
||||
throw new Error(
|
||||
`Failed POST request for OAuth token: ${tokenResponse.status} ${tokenResponse.statusText} at ${bambooHrOauthUrl} with ${'code' in oAuthInfo ? oAuthInfo.code : oAuthInfo.refreshToken}`
|
||||
)
|
||||
}
|
||||
|
||||
const tokenData = bambooHrOauthTokenResponse.safeParse(await tokenResponse.json())
|
||||
if (!tokenData.success) {
|
||||
throw new Error(`Failed parse OAuth token response: ${tokenData.error.message}`)
|
||||
}
|
||||
const { access_token, refresh_token, expires_in, scope, id_token } = tokenData.data
|
||||
|
||||
return {
|
||||
accessToken: access_token,
|
||||
refreshToken: refresh_token,
|
||||
expiresAt: requestTimestamp + expires_in * 1000 - OAUTH_EXPIRATION_MARGIN,
|
||||
scopes: scope,
|
||||
idToken: id_token,
|
||||
}
|
||||
}
|
||||
|
||||
export type BambooHRAuthorization = { authorization: string; expiresAt: number; domain: string } & (
|
||||
| {
|
||||
type: 'apiKey'
|
||||
}
|
||||
| {
|
||||
type: 'oauth'
|
||||
refreshToken: string
|
||||
}
|
||||
)
|
||||
|
||||
export const getCurrentBambooHrAuthorization = async ({
|
||||
ctx,
|
||||
client,
|
||||
}: types.CommonHandlerProps): Promise<BambooHRAuthorization> => {
|
||||
if (ctx.configurationType === 'manual') {
|
||||
return {
|
||||
type: 'apiKey',
|
||||
authorization: `Basic ${Buffer.from(ctx.configuration.apiKey + ':x').toString('base64')}`,
|
||||
expiresAt: Infinity,
|
||||
domain: stripSubdomain(ctx.configuration.subdomain),
|
||||
}
|
||||
}
|
||||
|
||||
let oauth: bp.states.States['oauth']['payload']
|
||||
try {
|
||||
const { state } = await client.getState({
|
||||
type: 'integration',
|
||||
name: 'oauth',
|
||||
id: ctx.integrationId,
|
||||
})
|
||||
oauth = state.payload
|
||||
} catch (err) {
|
||||
throw new Error('OAuth token missing in state for OAuth-linked integration.', { cause: err })
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'oauth',
|
||||
authorization: `Bearer ${oauth.accessToken}`,
|
||||
expiresAt: oauth.expiresAt,
|
||||
refreshToken: oauth.refreshToken,
|
||||
domain: oauth.domain,
|
||||
}
|
||||
}
|
||||
|
||||
export const refreshBambooHrAuthorization = async (
|
||||
{ ctx, client }: types.CommonHandlerProps,
|
||||
previousAuth: BambooHRAuthorization
|
||||
): Promise<BambooHRAuthorization> => {
|
||||
// Return the previous authorization if it is an API key
|
||||
if (previousAuth.type === 'apiKey') {
|
||||
return previousAuth
|
||||
}
|
||||
|
||||
const oauth = previousAuth
|
||||
|
||||
const { accessToken, expiresAt, refreshToken, scopes } = await _fetchBambooHrOauthToken({
|
||||
subdomain: oauth.domain,
|
||||
oAuthInfo: { refreshToken: oauth.refreshToken },
|
||||
})
|
||||
|
||||
await client.patchState({
|
||||
type: 'integration',
|
||||
name: 'oauth',
|
||||
id: ctx.integrationId,
|
||||
payload: {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresAt,
|
||||
scopes,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
type: 'oauth',
|
||||
authorization: `Bearer ${accessToken}`,
|
||||
expiresAt: oauth.expiresAt,
|
||||
refreshToken,
|
||||
domain: oauth.domain,
|
||||
}
|
||||
}
|
||||
|
||||
/** Handles OAuth endpoint on integration authentication.
|
||||
*
|
||||
* Exchanges code for token, saves token in state, and configures integration with identifier and subdomain.
|
||||
*/
|
||||
export const handleOauthRequest = async ({ ctx, client, req, logger }: bp.HandlerProps, subdomain: string) => {
|
||||
const code = new URLSearchParams(req.query).get('code')
|
||||
const redirectUri = new URLSearchParams(req.query).get('redirect_uri')
|
||||
|
||||
if (!code || !redirectUri) throw new Error('Missing authentication code or redirect URI')
|
||||
if (!subdomain) throw new Error('Subdomain is required')
|
||||
|
||||
const { ...oauthState } = await _fetchBambooHrOauthToken({
|
||||
subdomain,
|
||||
oAuthInfo: { code, redirectUri },
|
||||
}).catch((thrown) => {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new Error('Failed to fetch BambooHR OAuth token: ' + error.message)
|
||||
})
|
||||
|
||||
await client.setState({
|
||||
type: 'integration',
|
||||
name: 'oauth',
|
||||
id: ctx.integrationId,
|
||||
payload: { ...oauthState, domain: subdomain },
|
||||
})
|
||||
|
||||
await client.configureIntegration({
|
||||
identifier: subdomain,
|
||||
})
|
||||
|
||||
logger.forBot().info('BambooHR OAuth authentication successfully set up.')
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { type z } from '@botpress/sdk'
|
||||
|
||||
import {
|
||||
bambooHrCompanyInfo,
|
||||
bambooHrEmployeeBasicInfoResponse,
|
||||
bambooHrEmployeeCustomInfoResponse,
|
||||
bambooHrEmployeeDirectoryResponse,
|
||||
bambooHrFields,
|
||||
bambooHrWebhookCreateResponse,
|
||||
} from 'definitions'
|
||||
import * as types from '../types'
|
||||
import { BambooHRAuthorization, getCurrentBambooHrAuthorization, refreshBambooHrAuthorization } from './auth'
|
||||
import { parseResponseWithErrors } from './utils'
|
||||
|
||||
const getHeaders = (authorization: string) => ({
|
||||
Authorization: authorization,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
})
|
||||
|
||||
export class BambooHRClient {
|
||||
private _baseUrl: string
|
||||
private _currentAuth: BambooHRAuthorization
|
||||
private _props: types.CommonHandlerProps
|
||||
|
||||
public static async create(props: types.CommonHandlerProps): Promise<BambooHRClient> {
|
||||
const currentAuth = await getCurrentBambooHrAuthorization(props)
|
||||
return new BambooHRClient({ subdomain: currentAuth.domain, props, currentAuth })
|
||||
}
|
||||
|
||||
private constructor({
|
||||
subdomain,
|
||||
props,
|
||||
currentAuth,
|
||||
}: {
|
||||
subdomain: string
|
||||
props: types.CommonHandlerProps
|
||||
currentAuth: BambooHRAuthorization
|
||||
}) {
|
||||
this._baseUrl = `https://${subdomain}.bamboohr.com/api/v1`
|
||||
this._props = props
|
||||
this._currentAuth = currentAuth
|
||||
}
|
||||
|
||||
private async _makeRequest({
|
||||
url,
|
||||
...params
|
||||
}: Pick<RequestInit, 'method' | 'body'> & { url: URL }): Promise<Response> {
|
||||
// Refresh token if too close to expiry
|
||||
if (Date.now() >= this._currentAuth.expiresAt) {
|
||||
this._currentAuth = await refreshBambooHrAuthorization(this._props, this._currentAuth)
|
||||
}
|
||||
|
||||
const headers = getHeaders(this._currentAuth.authorization)
|
||||
const res = await fetch(url, { ...params, headers })
|
||||
if (!res.ok) {
|
||||
// Custom error header from BambooHR with more details
|
||||
const additionalInfo = res.headers.get('x-bamboohr-error-message')
|
||||
throw new Error(
|
||||
`BambooHR API request failed with status ${res.status} ${res.statusText}` +
|
||||
(additionalInfo ? `: ${additionalInfo}` : '')
|
||||
)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
public async testAuthorization(): Promise<boolean> {
|
||||
const url = new URL(`${this._baseUrl}/employees/0`)
|
||||
|
||||
const res = await this._makeRequest({ method: 'GET', url })
|
||||
return res.ok
|
||||
}
|
||||
|
||||
public async createWebhook(
|
||||
webhookUrl: string,
|
||||
fields: string[]
|
||||
): Promise<z.infer<typeof bambooHrWebhookCreateResponse>> {
|
||||
const url = new URL(`${this._baseUrl}/webhooks`)
|
||||
|
||||
const body = JSON.stringify({
|
||||
name: this._props.ctx.integrationId,
|
||||
monitorFields: fields.filter((field) => field !== 'terminationDate'), // terminationDate returns error on monitor
|
||||
postFields: fields.reduce((acc, field) => ({ ...acc, [field]: field }), {} as Record<string, string>),
|
||||
url: webhookUrl,
|
||||
format: 'json',
|
||||
limit: {
|
||||
times: 1000, // Send at most 1000 records per event
|
||||
seconds: 60, // Fire at most once per minute
|
||||
},
|
||||
})
|
||||
|
||||
const res = await this._makeRequest({ method: 'POST', url, body })
|
||||
const result = await parseResponseWithErrors(res, bambooHrWebhookCreateResponse)
|
||||
|
||||
if (!result.success) {
|
||||
this._props.logger.forBot().error(result.error, result.details)
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
public async deleteWebhook(webhookId: string): Promise<Response> {
|
||||
const url = new URL(`${this._baseUrl}/webhooks/${webhookId}`)
|
||||
|
||||
return await this._makeRequest({ method: 'DELETE', url })
|
||||
}
|
||||
|
||||
// API Methods
|
||||
|
||||
public async getMonitoredFields(): Promise<string[]> {
|
||||
const url = new URL(`${this._baseUrl}/webhooks/monitor_fields`)
|
||||
const res = await this._makeRequest({ method: 'GET', url })
|
||||
const result = await res.json()
|
||||
if ('fields' in result) {
|
||||
return result.fields.map((field: { alias: string }) => field.alias).filter((field: string) => field !== null)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
public async getCompanyInfo(): Promise<z.infer<typeof bambooHrCompanyInfo>> {
|
||||
const url = new URL(`${this._baseUrl}/company_information`)
|
||||
|
||||
const res = await this._makeRequest({ method: 'GET', url })
|
||||
const result = await parseResponseWithErrors(res, bambooHrCompanyInfo)
|
||||
|
||||
if (!result.success) {
|
||||
this._props.logger.forBot().error(result.error, result.details)
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
public async getFields(): Promise<z.infer<typeof bambooHrFields>> {
|
||||
const url = new URL(`${this._baseUrl}/meta/fields`)
|
||||
|
||||
const res = await this._makeRequest({ method: 'GET', url })
|
||||
const result = await parseResponseWithErrors(res, bambooHrFields)
|
||||
|
||||
if (!result.success) {
|
||||
this._props.logger.forBot().error(result.error, result.details)
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
public async getEmployeeBasicInfo(employeeId: string): Promise<z.infer<typeof bambooHrEmployeeBasicInfoResponse>> {
|
||||
const url = new URL(`${this._baseUrl}/employees/${employeeId}`)
|
||||
|
||||
const res = await this._makeRequest({ method: 'GET', url })
|
||||
const result = await parseResponseWithErrors(res, bambooHrEmployeeBasicInfoResponse)
|
||||
|
||||
if (!result.success) {
|
||||
this._props.logger.forBot().error(result.error, result.details)
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
public async getEmployeeCustomInfo(
|
||||
employeeId: string,
|
||||
fields: string[]
|
||||
): Promise<z.infer<typeof bambooHrEmployeeCustomInfoResponse>> {
|
||||
const url = new URL(`${this._baseUrl}/employees/${employeeId}`)
|
||||
url.searchParams.append('fields', fields.join(','))
|
||||
|
||||
const res = await this._makeRequest({ method: 'GET', url })
|
||||
const result = await parseResponseWithErrors(res, bambooHrEmployeeCustomInfoResponse)
|
||||
|
||||
if (!result.success) {
|
||||
this._props.logger.forBot().error(result.error, result.details)
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
public async getEmployeePhoto(employeeId: string, size: string): Promise<Blob> {
|
||||
const url = new URL(`${this._baseUrl}/employees/${employeeId}/photo/${size}`)
|
||||
|
||||
const res = await this._makeRequest({ method: 'GET', url })
|
||||
return await res.blob()
|
||||
}
|
||||
|
||||
public async listEmployees(): Promise<z.infer<typeof bambooHrEmployeeDirectoryResponse>> {
|
||||
const url = new URL(`${this._baseUrl}/employees/directory`)
|
||||
const res = await this._makeRequest({ method: 'GET', url })
|
||||
const result = await parseResponseWithErrors(res, bambooHrEmployeeDirectoryResponse)
|
||||
|
||||
if (!result.success) {
|
||||
this._props.logger.forBot().error(result.error, result.details)
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import { createHmac, timingSafeEqual } from 'crypto'
|
||||
|
||||
export type ValidateBambooHrSignatureResult =
|
||||
| {
|
||||
success: true
|
||||
}
|
||||
| {
|
||||
success: false
|
||||
reason: string
|
||||
}
|
||||
|
||||
export const validateBambooHrSignature = async (
|
||||
req: sdk.Request,
|
||||
privateKey: string
|
||||
): Promise<ValidateBambooHrSignatureResult> => {
|
||||
const signature = req.headers?.['x-bamboohr-signature']
|
||||
const timestamp = req.headers?.['x-bamboohr-timestamp']
|
||||
if (!signature || !timestamp) {
|
||||
return { success: false, reason: 'Missing signature headers to verify webhook event.' }
|
||||
}
|
||||
if (!req.body) {
|
||||
return { success: false, reason: 'No request body found to verify signature.' }
|
||||
}
|
||||
|
||||
const computedBuffer = createHmac('sha256', privateKey)
|
||||
.update(Buffer.concat([Buffer.from(req.body, 'utf8'), Buffer.from(timestamp, 'utf8')]))
|
||||
.digest()
|
||||
const signatureBuffer = Buffer.from(signature, 'hex')
|
||||
const isValid = computedBuffer.length === signatureBuffer.length && timingSafeEqual(computedBuffer, signatureBuffer)
|
||||
|
||||
if (!isValid) {
|
||||
return { success: false, reason: 'Invalid BambooHR webhook signature.' }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type z } from '@botpress/sdk'
|
||||
|
||||
export type ParseResult<T> = { success: true; data: T } | { success: false; error: string; details?: unknown }
|
||||
|
||||
export const parseResponseWithErrors = async <T>(res: Response, schema: z.ZodSchema<T>): Promise<ParseResult<T>> => {
|
||||
let json: unknown
|
||||
try {
|
||||
json = await res.json()
|
||||
} catch (thrown) {
|
||||
const err = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'BambooHR API response is not valid JSON',
|
||||
details: err,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const data = schema.parse(json)
|
||||
return { success: true, data }
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'BambooHR API response did not match expected format',
|
||||
details: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const safeParseJson = <T>(str: string): ParseResult<T> => {
|
||||
try {
|
||||
const parsed = JSON.parse(str)
|
||||
return { success: true, data: parsed }
|
||||
} catch (thrown) {
|
||||
const error = thrown instanceof Error ? thrown.message : String(thrown)
|
||||
return { success: false, error }
|
||||
}
|
||||
}
|
||||
|
||||
export const stripSubdomain = (input: string): string => {
|
||||
const trimmedInput = input.trim()
|
||||
|
||||
// Remove protocol if present
|
||||
const withoutProtocol = trimmedInput.replace(/^https?:\/\//, '')
|
||||
|
||||
const suffix = '.bamboohr.com'
|
||||
if (withoutProtocol.endsWith(suffix)) {
|
||||
return withoutProtocol.slice(0, -suffix.length)
|
||||
}
|
||||
|
||||
return trimmedInput
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { RuntimeError } from '@botpress/client'
|
||||
|
||||
export class BambooHRRuntimeError extends RuntimeError {
|
||||
public static from(thrown: unknown, ctx: string): BambooHRRuntimeError {
|
||||
const errMessage = thrown instanceof Error ? thrown.message : String(thrown)
|
||||
return new BambooHRRuntimeError(`${ctx}: ${errMessage}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { z } from '@botpress/sdk'
|
||||
import {
|
||||
bambooHrEmployeeWebhookFields,
|
||||
type bambooHrEmployeeCreatedEvent,
|
||||
type bambooHrEmployeeDeletedEvent,
|
||||
type bambooHrEmployeeUpdatedEvent,
|
||||
} from 'definitions'
|
||||
import mapValues from 'lodash/mapValues'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const handleEmployeeCreatedEvent = async (
|
||||
{ client }: bp.HandlerProps,
|
||||
event: z.output<typeof bambooHrEmployeeCreatedEvent>
|
||||
) => {
|
||||
const { id, timestamp } = event
|
||||
await client.createEvent({
|
||||
type: 'employeeCreated',
|
||||
payload: {
|
||||
id,
|
||||
timestamp,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const handleEmployeeDeletedEvent = async (
|
||||
{ client }: bp.HandlerProps,
|
||||
event: z.output<typeof bambooHrEmployeeDeletedEvent>
|
||||
) => {
|
||||
const { id, timestamp } = event
|
||||
await client.createEvent({
|
||||
type: 'employeeDeleted',
|
||||
payload: {
|
||||
id,
|
||||
timestamp,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const handleEmployeeUpdatedEvent = async (
|
||||
{ client }: bp.HandlerProps,
|
||||
event: z.output<typeof bambooHrEmployeeUpdatedEvent>
|
||||
) => {
|
||||
const { id, timestamp, fields, changedFields } = event
|
||||
|
||||
await client.createEvent({
|
||||
type: 'employeeUpdated',
|
||||
payload: {
|
||||
id,
|
||||
timestamp,
|
||||
changedFields,
|
||||
fields: bambooHrEmployeeWebhookFields.parse(mapValues(fields, ({ value }) => value)),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { bambooHrEmployeeWebhookEvent } from 'definitions'
|
||||
import { validateBambooHrSignature } from './api/signing'
|
||||
import { safeParseJson } from './api/utils'
|
||||
import { BambooHRRuntimeError } from './error-handling'
|
||||
import { handleEmployeeCreatedEvent, handleEmployeeDeletedEvent, handleEmployeeUpdatedEvent } from './events'
|
||||
import { handler as oauthHandler } from './handlers/oauth'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const _isOauthRequest = ({ req }: bp.HandlerProps) => req.path.startsWith('/oauth')
|
||||
|
||||
export const handler: bp.IntegrationProps['handler'] = async (props) => {
|
||||
const { req, logger } = props
|
||||
if (_isOauthRequest(props)) {
|
||||
return await oauthHandler(props).catch((thrown) => {
|
||||
const err = BambooHRRuntimeError.from(thrown, 'Error in OAuth creation flow')
|
||||
logger.forBot().error(err.message)
|
||||
return { status: 500, body: err.message }
|
||||
})
|
||||
}
|
||||
|
||||
const { state } = await props.client.getState({
|
||||
name: 'webhook',
|
||||
type: 'integration',
|
||||
id: props.ctx.integrationId,
|
||||
})
|
||||
const privateKey = state.payload.privateKey
|
||||
if (!privateKey) {
|
||||
logger.forBot().error('No private key found for webhook state.')
|
||||
return
|
||||
}
|
||||
|
||||
const signingResult = await validateBambooHrSignature(props.req, privateKey)
|
||||
if (!signingResult.success) {
|
||||
logger.forBot().error('HMAC signature validation failed: ' + signingResult.reason)
|
||||
return
|
||||
}
|
||||
|
||||
const jsonParseResult = safeParseJson(req.body ?? '')
|
||||
if (!jsonParseResult.success) {
|
||||
logger.forBot().error('Error parsing request body: ' + jsonParseResult.error)
|
||||
return
|
||||
}
|
||||
|
||||
const zodParseResult = bambooHrEmployeeWebhookEvent.safeParse(jsonParseResult.data)
|
||||
if (!zodParseResult.success) {
|
||||
logger.forBot().error('Error parsing request body: ' + zodParseResult.error.message)
|
||||
return
|
||||
}
|
||||
|
||||
const event = zodParseResult.data
|
||||
|
||||
await Promise.all(
|
||||
event.employees.map(async (employee) => {
|
||||
const { action, id, timestamp } = employee
|
||||
logger.forBot().info(`Sending employee ${action.toLowerCase()} event for ID ${id} at ${timestamp}`)
|
||||
|
||||
switch (action) {
|
||||
case 'Created':
|
||||
await handleEmployeeCreatedEvent(props, employee)
|
||||
break
|
||||
case 'Deleted':
|
||||
await handleEmployeeDeletedEvent(props, employee)
|
||||
break
|
||||
case 'Updated':
|
||||
await handleEmployeeUpdatedEvent(props, employee)
|
||||
break
|
||||
default:
|
||||
logger.forBot().warn(`Unknown action type: ${action}`)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return { status: 200 }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user