diff --git a/integrations/airtable/integration.definition.ts b/integrations/airtable/integration.definition.ts index 57bd6beffc2..5da94128a49 100644 --- a/integrations/airtable/integration.definition.ts +++ b/integrations/airtable/integration.definition.ts @@ -19,20 +19,14 @@ export default new IntegrationDefinition({ title: 'Airtable', description: 'Access and manage Airtable data to allow your chatbot to retrieve details, update records, and organize information.', - version: '2.0.0', + version: '3.0.0', readme: 'hub.md', icon: 'icon.svg', configuration: { - schema: z.object({ - accessToken: z.string().describe('Personal Access Token').title('Personal Access Token'), - baseId: z.string().describe('Base ID').title('Base ID'), - endpointUrl: z - .string() - .optional() - .default('https://api.airtable.com/v0/') - .describe('API endpoint to hit (Default: https://api.airtable.com/v0/)') - .title('Endpoint Url'), - }), + identifier: { + linkTemplateScript: 'linkTemplate.vrl', + }, + schema: z.object({}), }, channels: {}, user: { @@ -101,7 +95,46 @@ export default new IntegrationDefinition({ }, }, events: {}, - states: {}, + states: { + oAuthCredentials: { + type: 'integration', + schema: z.object({ + accessToken: z.string().secret().describe('The OAuth access token'), + refreshToken: z.string().secret().describe('The rotating OAuth refresh token'), + expiresAt: z.string().datetime().describe('The timestamp of when the access token expires'), + refreshExpiresAt: z.string().datetime().describe('The timestamp of when the refresh token expires'), + scopes: z.array(z.string()).describe('The scopes granted to the token'), + }), + }, + manualCredentials: { + type: 'integration', + schema: z.object({ + personalAccessToken: z.string().secret().describe('The Airtable Personal Access Token'), + }), + }, + oauthPkce: { + type: 'integration', + schema: z.object({ + codeVerifier: z.string().describe('The PKCE code verifier paired with the in-flight authorization request'), + createdAt: z.string().datetime().describe('The timestamp of when the code verifier was issued'), + }), + }, + configuration: { + type: 'integration', + schema: z.object({ + baseId: z.string().describe('The selected Airtable base ID'), + endpointUrl: z.string().optional().describe('Optional override for the Airtable API endpoint'), + }), + }, + }, + secrets: { + CLIENT_ID: { + description: 'The client ID of the Airtable OAuth app.', + }, + CLIENT_SECRET: { + description: 'The client secret of the Airtable OAuth app.', + }, + }, attributes: { category: 'Project Management', repo: 'botpress', diff --git a/integrations/airtable/linkTemplate.vrl b/integrations/airtable/linkTemplate.vrl new file mode 100644 index 00000000000..23372049f7a --- /dev/null +++ b/integrations/airtable/linkTemplate.vrl @@ -0,0 +1,4 @@ +webhookId = to_string!(.webhookId) +webhookUrl = to_string!(.webhookUrl) + +"{{ webhookUrl }}/oauth/wizard/start?state={{ webhookId }}" diff --git a/integrations/airtable/src/actions/record.ts b/integrations/airtable/src/actions/record.ts index 61e27dc0f94..9209eca8892 100644 --- a/integrations/airtable/src/actions/record.ts +++ b/integrations/airtable/src/actions/record.ts @@ -1,5 +1,5 @@ import { FieldSet } from 'airtable/lib/field_set' -import { AirtableApi } from '../client' +import { AirtableClient } from '../airtable-api/airtable-client' import type { FieldValue } from '../misc/field-schemas' import type { IntegrationProps } from '../misc/types' @@ -12,21 +12,21 @@ function toFieldSet(fields: FieldValue[]): FieldSet { } export const createRecord: IntegrationProps['actions']['createRecord'] = async ({ client, ctx, logger, input }) => { - const airtableClient = new AirtableApi({ client, ctx, logger }) + const airtableClient = await AirtableClient.createFromStates({ client, ctx, logger }) const record = await airtableClient.createRecord(input.tableIdOrName, toFieldSet(input.fields)) logger.forBot().info(`Successful - Create Record - ${record.id}`) return record } export const updateRecord: IntegrationProps['actions']['updateRecord'] = async ({ client, ctx, logger, input }) => { - const airtableClient = new AirtableApi({ client, ctx, logger }) + const airtableClient = await AirtableClient.createFromStates({ client, ctx, logger }) const record = await airtableClient.updateRecord(input.tableIdOrName, input.recordId, toFieldSet(input.fields)) logger.forBot().info(`Successful - Update Record - ${record.id}`) return record } export const listRecords: IntegrationProps['actions']['listRecords'] = async ({ client, ctx, logger, input }) => { - const airtableClient = new AirtableApi({ client, ctx, logger }) + const airtableClient = await AirtableClient.createFromStates({ client, ctx, logger }) const records = await airtableClient.listRecords({ tableIdOrName: input.tableIdOrName, filterByFormula: input.filterByFormula, diff --git a/integrations/airtable/src/actions/table.ts b/integrations/airtable/src/actions/table.ts index 676aa1e268d..d2bdc97360d 100644 --- a/integrations/airtable/src/actions/table.ts +++ b/integrations/airtable/src/actions/table.ts @@ -1,15 +1,15 @@ -import { AirtableApi } from '../client' +import { AirtableClient } from '../airtable-api/airtable-client' import type { IntegrationProps } from '../misc/types' export const createTable: IntegrationProps['actions']['createTable'] = async ({ client, ctx, logger, input }) => { - const airtableClient = new AirtableApi({ client, ctx, logger }) + const airtableClient = await AirtableClient.createFromStates({ client, ctx, logger }) const table = await airtableClient.createTable(input.name, input.fields, input.description) logger.forBot().info(`Successful - Create Table - ${table.id} - ${table.name}`) return table } export const updateTable: IntegrationProps['actions']['updateTable'] = async ({ client, ctx, logger, input }) => { - const airtableClient = new AirtableApi({ client, ctx, logger }) + const airtableClient = await AirtableClient.createFromStates({ client, ctx, logger }) const table = await airtableClient.updateTable(input.tableIdOrName, input.name, input.description) logger.forBot().info(`Successful - Update Table - ${table.id} - ${table.name}`) return table @@ -21,7 +21,7 @@ export const getTableRecords: IntegrationProps['actions']['getTableRecords'] = a logger, input, }) => { - const airtableClient = new AirtableApi({ client, ctx, logger }) + const airtableClient = await AirtableClient.createFromStates({ client, ctx, logger }) const records = await airtableClient.listRecords({ tableIdOrName: input.tableIdOrName, nextToken: input.nextToken, diff --git a/integrations/airtable/src/client.ts b/integrations/airtable/src/airtable-api/airtable-client.ts similarity index 54% rename from integrations/airtable/src/client.ts rename to integrations/airtable/src/airtable-api/airtable-client.ts index f5e6624ae9d..cf765d25819 100644 --- a/integrations/airtable/src/client.ts +++ b/integrations/airtable/src/airtable-api/airtable-client.ts @@ -1,11 +1,13 @@ import Airtable, { type FieldSet } from 'airtable' -import axios, { AxiosInstance, AxiosResponse } from 'axios' +import axios, { AxiosInstance } from 'axios' import { stringify } from 'querystring' -import { handleErrorsDecorator } from './api/error-handling' -import type { CreatableField } from './misc/field-schemas' +import { handleErrorsDecorator } from '../api/error-handling' +import type { CreatableField } from '../misc/field-schemas' +import { AirtableOAuthClient } from './airtable-oauth-client' import * as bp from '.botpress' const handleErrors = handleErrorsDecorator + type TableResponse = { id: string name: string @@ -20,33 +22,85 @@ type RecordResponse = { fields: FieldSet } -export class AirtableApi { - private _base: Airtable.Base - private _axiosClient: AxiosInstance - private _baseId: string - private _logger: bp.Logger - - public constructor({ ctx, logger }: bp.CommonHandlerProps) { - this._logger = logger - const endpointUrl = ctx.configuration.endpointUrl || 'https://api.airtable.com/v0/' - const apiKey = ctx.configuration.accessToken - const baseId = ctx.configuration.baseId +type CreateProps = { + client: bp.Client + ctx: bp.Context + logger: bp.Logger +} + +export class AirtableClient { + private readonly _baseId: string + private readonly _base: Airtable.Base + private readonly _axiosClient: AxiosInstance + + private constructor({ + baseId, + accessToken, + endpointUrl, + }: { + logger: bp.Logger + baseId: string + accessToken: string + endpointUrl: string + }) { this._baseId = baseId - // This split is done because the Airtable SDK appends /v0/ path itself - this._base = new Airtable({ apiKey, endpointUrl: endpointUrl.replace('/v0/', '') }).base(baseId) + // The Airtable SDK appends /v0/ itself, so strip it from the configured endpoint. + this._base = new Airtable({ apiKey: accessToken, endpointUrl: endpointUrl.replace('/v0/', '') }).base(baseId) this._axiosClient = axios.create({ baseURL: endpointUrl, headers: { - Authorization: `Bearer ${apiKey}`, + Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, }) } + public static async createFromStates({ client, ctx, logger }: CreateProps): Promise { + 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 { + 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 { - return await this._axiosClient.get('/meta/whoami') + public async testConnection(): Promise<{ id: string; email?: string; scopes?: string[] }> { + const response = await this._axiosClient.get<{ id: string; email?: string; scopes?: string[] }>('/meta/whoami') + return response.data } @handleErrors('Failed to list records') diff --git a/integrations/airtable/src/airtable-api/airtable-oauth-client.ts b/integrations/airtable/src/airtable-api/airtable-oauth-client.ts new file mode 100644 index 00000000000..b83e597f1ca --- /dev/null +++ b/integrations/airtable/src/airtable-api/airtable-oauth-client.ts @@ -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 { + 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 { + await this._client.setState({ + type: 'integration', + name: 'manualCredentials', + id: this._ctx.integrationId, + payload: { personalAccessToken }, + }) + await this._clearOAuthCredentials() + } + + private async _postToken(body: Record): Promise { + const basicAuth = Buffer.from(`${this._clientId}:${this._clientSecret}`).toString('base64') + const response = await axios.post(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 { + 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, + }, + }) + } +} diff --git a/integrations/airtable/src/index.ts b/integrations/airtable/src/index.ts index 7d1e3c89aea..23358f6b9d9 100644 --- a/integrations/airtable/src/index.ts +++ b/integrations/airtable/src/index.ts @@ -1,17 +1,30 @@ +import { isOAuthWizardUrl } from '@botpress/common/src/oauth-wizard' +import * as sdk from '@botpress/sdk' import actions from './actions' -import { AirtableApi } from './client' +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 }) => { - const airtableClient = new AirtableApi({ client, ctx, logger }) - - await airtableClient.testConnection() + 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 () => {}, + handler: async (props) => { + if (isOAuthWizardUrl(props.req.path)) { + return await oauthWizardHandler(props) + } + return + }, }) diff --git a/integrations/airtable/src/oauth-wizard/index.ts b/integrations/airtable/src/oauth-wizard/index.ts new file mode 100644 index 00000000000..95a2a8bd3ac --- /dev/null +++ b/integrations/airtable/src/oauth-wizard/index.ts @@ -0,0 +1,24 @@ +import { generateRedirection } from '@botpress/common/src/html-dialogs' +import { isOAuthWizardUrl, getInterstitialUrl } 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)) + } +} diff --git a/integrations/airtable/src/oauth-wizard/wizard.ts b/integrations/airtable/src/oauth-wizard/wizard.ts new file mode 100644 index 00000000000..8ae28dcb097 --- /dev/null +++ b/integrations/airtable/src/oauth-wizard/wizard.ts @@ -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 + +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.
' + + 'You can create a token in the Airtable developer hub.', + schema: _manualCredentialsSchema, + nextStepId: 'save-manual-credentials', +} + +export const handler = async (props: bp.HandlerProps): Promise => { + 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, + }) + } + + 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 }) +} diff --git a/integrations/airtable/tsconfig.json b/integrations/airtable/tsconfig.json index efa8d581aaa..ab63b46f9ad 100644 --- a/integrations/airtable/tsconfig.json +++ b/integrations/airtable/tsconfig.json @@ -3,7 +3,9 @@ "compilerOptions": { "paths": { "*": ["./*"] }, "outDir": "dist", - "experimentalDecorators": true + "experimentalDecorators": true, + "jsx": "react-jsx", + "jsxImportSource": "preact" }, "include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"] } diff --git a/integrations/chat/src/metrics-server.ts b/integrations/chat/src/metrics-server.ts index b87c97ac05c..e0b644c58de 100644 --- a/integrations/chat/src/metrics-server.ts +++ b/integrations/chat/src/metrics-server.ts @@ -34,6 +34,6 @@ export const startMetricsServer = (port: number) => { }) server.listen(port, () => { - console.log(`Metrics server listening on port ${port}`) + console.info(`Metrics server listening on port ${port}`) }) } diff --git a/package.json b/package.json index f47b7c310d7..145f943ccf3 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ }, "devDependencies": { "@aws-sdk/client-dynamodb": "^3.564.0", - "@botpress/api": "1.95.0", + "@botpress/api": "1.99.0", "@botpress/cli": "workspace:*", "@botpress/client": "workspace:*", "@botpress/sdk": "workspace:*", diff --git a/packages/cli/package.json b/packages/cli/package.json index 30163dc61e5..8ea38f153d6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@botpress/cli", - "version": "6.5.0", + "version": "6.6.0", "description": "Botpress CLI", "scripts": { "build": "pnpm run build:types && pnpm run bundle && pnpm run template:gen", @@ -27,8 +27,8 @@ "dependencies": { "@apidevtools/json-schema-ref-parser": "^11.7.0", "@botpress/chat": "0.5.5", - "@botpress/client": "1.42.0", - "@botpress/sdk": "6.7.0", + "@botpress/client": "1.43.0", + "@botpress/sdk": "6.8.0", "@bpinternal/const": "^0.1.0", "@bpinternal/tunnel": "^0.1.1", "@bpinternal/verel": "^0.2.0", diff --git a/packages/cli/templates/empty-bot/package.json b/packages/cli/templates/empty-bot/package.json index 3ce00fe0e55..bdb672465b5 100644 --- a/packages/cli/templates/empty-bot/package.json +++ b/packages/cli/templates/empty-bot/package.json @@ -5,8 +5,8 @@ }, "private": true, "dependencies": { - "@botpress/client": "1.42.0", - "@botpress/sdk": "6.7.0" + "@botpress/client": "1.43.0", + "@botpress/sdk": "6.8.0" }, "devDependencies": { "@types/node": "^22.16.4", diff --git a/packages/cli/templates/empty-integration/package.json b/packages/cli/templates/empty-integration/package.json index b72f800547d..7075ab2e2b3 100644 --- a/packages/cli/templates/empty-integration/package.json +++ b/packages/cli/templates/empty-integration/package.json @@ -6,8 +6,8 @@ }, "private": true, "dependencies": { - "@botpress/client": "1.42.0", - "@botpress/sdk": "6.7.0" + "@botpress/client": "1.43.0", + "@botpress/sdk": "6.8.0" }, "devDependencies": { "@types/node": "^22.16.4", diff --git a/packages/cli/templates/empty-plugin/package.json b/packages/cli/templates/empty-plugin/package.json index 05fdb31f584..1803cdd97bd 100644 --- a/packages/cli/templates/empty-plugin/package.json +++ b/packages/cli/templates/empty-plugin/package.json @@ -6,7 +6,7 @@ }, "private": true, "dependencies": { - "@botpress/sdk": "6.7.0" + "@botpress/sdk": "6.8.0" }, "devDependencies": { "@types/node": "^22.16.4", diff --git a/packages/cli/templates/hello-world/package.json b/packages/cli/templates/hello-world/package.json index 7dd4e1c6539..45758149eef 100644 --- a/packages/cli/templates/hello-world/package.json +++ b/packages/cli/templates/hello-world/package.json @@ -6,8 +6,8 @@ }, "private": true, "dependencies": { - "@botpress/client": "1.42.0", - "@botpress/sdk": "6.7.0" + "@botpress/client": "1.43.0", + "@botpress/sdk": "6.8.0" }, "devDependencies": { "@types/node": "^22.16.4", diff --git a/packages/cli/templates/webhook-message/package.json b/packages/cli/templates/webhook-message/package.json index 68c13f2d4c4..acce1891bac 100644 --- a/packages/cli/templates/webhook-message/package.json +++ b/packages/cli/templates/webhook-message/package.json @@ -6,8 +6,8 @@ }, "private": true, "dependencies": { - "@botpress/client": "1.42.0", - "@botpress/sdk": "6.7.0", + "@botpress/client": "1.43.0", + "@botpress/sdk": "6.8.0", "axios": "^1.6.8" }, "devDependencies": { diff --git a/packages/client/package.json b/packages/client/package.json index d4f7c860a31..e5371f2f73d 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@botpress/client", - "version": "1.42.0", + "version": "1.43.0", "description": "Botpress Client", "main": "./dist/index.cjs", "module": "./dist/index.mjs", diff --git a/packages/llmz/package.json b/packages/llmz/package.json index 0205561b7f8..37eb71b055e 100644 --- a/packages/llmz/package.json +++ b/packages/llmz/package.json @@ -2,7 +2,7 @@ "name": "llmz", "type": "module", "description": "LLMz - An LLM-native Typescript VM built on top of Zui", - "version": "0.0.73", + "version": "0.0.74", "types": "./dist/index.d.ts", "main": "./dist/index.cjs", "module": "./dist/index.js", @@ -71,7 +71,7 @@ "tsx": "^4.19.2" }, "peerDependencies": { - "@botpress/client": "1.42.0", + "@botpress/client": "1.43.0", "@botpress/cognitive": "0.5.3", "@bpinternal/thicktoken": "^2.0.0", "@bpinternal/zui": "^2.1.1" diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 04416eda62b..432bb0eee90 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@botpress/sdk", - "version": "6.7.0", + "version": "6.8.0", "description": "Botpress SDK", "main": "./dist/index.cjs", "module": "./dist/index.mjs", @@ -20,7 +20,7 @@ "author": "", "license": "MIT", "dependencies": { - "@botpress/client": "1.42.0", + "@botpress/client": "1.43.0", "browser-or-node": "^2.1.1", "semver": "^7.3.8" }, diff --git a/packages/vai/package.json b/packages/vai/package.json index ae3c3c73d05..e2bb4d12be2 100644 --- a/packages/vai/package.json +++ b/packages/vai/package.json @@ -1,6 +1,6 @@ { "name": "@botpress/vai", - "version": "0.0.31", + "version": "0.0.32", "description": "Vitest AI (vai) – a vitest extension for testing with LLMs", "types": "./dist/index.d.ts", "exports": { @@ -40,7 +40,7 @@ "tsup": "^8.0.2" }, "peerDependencies": { - "@botpress/client": "1.42.0", + "@botpress/client": "1.43.0", "@bpinternal/thicktoken": "^1.0.1", "@bpinternal/zui": "^2.1.1", "lodash": "^4.17.21", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8206c1c9803..ce364fd6053 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,8 +17,8 @@ importers: specifier: ^3.564.0 version: 3.709.0 '@botpress/api': - specifier: 1.95.0 - version: 1.95.0 + specifier: 1.99.0 + version: 1.99.0 '@botpress/cli': specifier: workspace:* version: link:packages/cli @@ -2667,10 +2667,10 @@ importers: specifier: 0.5.5 version: link:../chat-client '@botpress/client': - specifier: 1.42.0 + specifier: 1.43.0 version: link:../client '@botpress/sdk': - specifier: 6.7.0 + specifier: 6.8.0 version: link:../sdk '@bpinternal/const': specifier: ^0.1.0 @@ -2791,10 +2791,10 @@ importers: packages/cli/templates/empty-bot: dependencies: '@botpress/client': - specifier: 1.42.0 + specifier: 1.43.0 version: link:../../../client '@botpress/sdk': - specifier: 6.7.0 + specifier: 6.8.0 version: link:../../../sdk devDependencies: '@types/node': @@ -2807,10 +2807,10 @@ importers: packages/cli/templates/empty-integration: dependencies: '@botpress/client': - specifier: 1.42.0 + specifier: 1.43.0 version: link:../../../client '@botpress/sdk': - specifier: 6.7.0 + specifier: 6.8.0 version: link:../../../sdk devDependencies: '@types/node': @@ -2823,7 +2823,7 @@ importers: packages/cli/templates/empty-plugin: dependencies: '@botpress/sdk': - specifier: 6.7.0 + specifier: 6.8.0 version: link:../../../sdk devDependencies: '@types/node': @@ -2836,10 +2836,10 @@ importers: packages/cli/templates/hello-world: dependencies: '@botpress/client': - specifier: 1.42.0 + specifier: 1.43.0 version: link:../../../client '@botpress/sdk': - specifier: 6.7.0 + specifier: 6.8.0 version: link:../../../sdk devDependencies: '@types/node': @@ -2852,10 +2852,10 @@ importers: packages/cli/templates/webhook-message: dependencies: '@botpress/client': - specifier: 1.42.0 + specifier: 1.43.0 version: link:../../../client '@botpress/sdk': - specifier: 6.7.0 + specifier: 6.8.0 version: link:../../../sdk axios: specifier: ^1.6.8 @@ -3006,7 +3006,7 @@ importers: specifier: ^7.26.3 version: 7.26.9 '@botpress/client': - specifier: 1.42.0 + specifier: 1.43.0 version: link:../client '@botpress/cognitive': specifier: 0.5.3 @@ -3112,7 +3112,7 @@ importers: packages/sdk: dependencies: '@botpress/client': - specifier: 1.42.0 + specifier: 1.43.0 version: link:../client '@bpinternal/zui': specifier: ^2.1.1 @@ -3149,7 +3149,7 @@ importers: packages/vai: dependencies: '@botpress/client': - specifier: 1.42.0 + specifier: 1.43.0 version: link:../client '@bpinternal/thicktoken': specifier: ^1.0.1 @@ -4179,8 +4179,8 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} - '@botpress/api@1.95.0': - resolution: {integrity: sha512-6S7qzvAkrpVjURFWdXhDe8NPqvzUDZytXnzUyKCxR1eK+VhLAPnyZvDuzwZ0knJuyLoA6rsxNedPCPwXWayI/Q==} + '@botpress/api@1.99.0': + resolution: {integrity: sha512-3tKkidEQL6DU9AmxhRqhGWDhctMiRIZUbyPidkvLv64AOUjgMmFxtlTwpqHch9ECLyT2BWGwu6PP2ekE4tnXxQ==} '@bpinternal/const@0.1.0': resolution: {integrity: sha512-iIQg9oYYXOt+LSK34oNhJVQTcgRdtLmLZirEUaE+R9hnmbKONA5reR2kTewxZmekGyxej+5RtDK9xrC/0hmeAw==} @@ -13981,7 +13981,7 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} - '@botpress/api@1.95.0': + '@botpress/api@1.99.0': dependencies: '@bpinternal/opapi': 1.0.0(openapi-types@12.1.3) transitivePeerDependencies: