chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
@@ -0,0 +1,40 @@
import * as sdk from '@botpress/sdk'
const makeApiRequest = {
title: 'Make API Request',
description: 'Make a request to Salesforce API',
input: {
schema: sdk.z.object({
method: sdk.z.string().title('Method').describe('HTTP method (GET, POST, PATCH, DELETE)'),
path: sdk.z.string().title('Path').describe('yourinstance.salesforce.com/services/data/v54.0/PATH'),
headers: sdk.z.string().optional().title('Headers').describe('Headers in JSON format'),
params: sdk.z.string().optional().title('Params').describe('Params in JSON format'),
requestBody: sdk.z
.string()
.displayAs<any>({
id: 'text',
params: {
allowDynamicVariable: true,
growVertically: true,
multiLine: true,
resizable: true,
},
})
.optional()
.title('Request Body')
.describe('Request body in JSON format'),
}),
},
output: {
schema: sdk.z.object({
success: sdk.z.boolean().title('Success').describe('Whether the request was successful'),
status: sdk.z.number().optional().title('Status').describe('HTTP status code of the response'),
body: sdk.z.any().optional().title('Body').describe('Response body from the API'),
error: sdk.z.string().optional().title('Error').describe('Error message if the request failed'),
}),
},
} satisfies sdk.ActionDefinition
export const apiActionDefinitions = {
makeApiRequest,
} satisfies sdk.IntegrationDefinitionProps['actions']
@@ -0,0 +1,69 @@
import * as sdk from '@botpress/sdk'
import { recordResultSchema, searchOutputSchema } from './common-schemas'
const createCaseInputSchema = sdk.z.object({
Subject: sdk.z.string().title('Subject').describe('The subject of the case'),
Description: sdk.z.string().title('Description').describe('The description of the case'),
Status: sdk.z.string().optional().title('Status').describe('The status of the case'),
customFields: sdk.z
.string()
.displayAs<any>({
id: 'text',
params: {
allowDynamicVariable: true,
growVertically: true,
multiLine: true,
resizable: true,
},
})
.optional()
.title('Custom Fields')
.describe('Additional fields in JSON format'),
})
const createCase = {
title: 'Create Case',
description: 'Create a Salesforce Case',
input: {
schema: createCaseInputSchema,
},
output: {
schema: recordResultSchema,
},
} satisfies sdk.ActionDefinition
const updateCase = {
title: 'Update Case',
description: 'Update a Salesforce Case',
input: {
schema: sdk.z.object({
Id: sdk.z.string().title('ID').describe('The ID of the case'),
...createCaseInputSchema.partial().shape,
}),
},
output: {
schema: recordResultSchema,
},
} satisfies sdk.ActionDefinition
const searchCases = {
title: 'Search Cases',
description: 'Search Salesforce Cases',
input: {
schema: sdk.z.object({
Id: sdk.z.string().optional().title('ID').describe('The ID of the case'),
Subject: sdk.z.string().optional().title('Subject').describe('The subject of the case'),
Description: sdk.z.string().optional().title('Description').describe('The description of the case'),
Status: sdk.z.string().optional().title('Status').describe('The status of the case'),
}),
},
output: {
schema: searchOutputSchema,
},
} satisfies sdk.ActionDefinition
export const caseActionDefinitions = {
createCase,
searchCases,
updateCase,
} satisfies sdk.IntegrationDefinitionProps['actions']
@@ -0,0 +1,21 @@
import { z } from '@botpress/sdk'
export const recordResultSchema = z.object({
id: z.string().optional().title('ID').describe('The ID of the created or updated record'),
success: z.boolean().title('Success').describe('Whether the operation was successful'),
error: z.string().optional().title('Error').describe('Error message if the operation failed'),
})
export type RecordResult = z.infer<typeof recordResultSchema>
export const searchOutputSchema = z.object({
success: z.boolean().title('Success').describe('Whether the search was successful'),
records: z
.array(z.object({}).passthrough())
.optional()
.title('Records')
.describe('The records returned by the search'),
error: z.string().optional().title('Error').describe('Error message if the search failed'),
})
export type SearchOutput = z.infer<typeof searchOutputSchema>
@@ -0,0 +1,78 @@
import * as sdk from '@botpress/sdk'
import { recordResultSchema, searchOutputSchema } from './common-schemas'
const createContactInputSchema = sdk.z.object({
FirstName: sdk.z.string().title('First Name').describe('The first name of the contact (e.g. John)'),
LastName: sdk.z.string().title('Last Name').describe('The last name of the contact (e.g. Doe)'),
Email: sdk.z.string().email().title('Email').describe('The email address of the contact (e.g. john.doe@example.com)'),
Phone: sdk.z
.string()
.optional()
.title('Phone')
.describe('The phone number of the contact (Optional) (e.g. +1-555-1234)'),
customFields: sdk.z
.string()
.displayAs<any>({
id: 'text',
params: {
allowDynamicVariable: true,
growVertically: true,
multiLine: true,
resizable: true,
},
})
.optional()
.title('Custom Fields')
.describe('Custom fields (JSON)'),
})
const createContact = {
title: 'Create Contact',
description: 'Create a Salesforce Contact',
input: {
schema: createContactInputSchema,
},
output: {
schema: recordResultSchema,
},
} satisfies sdk.ActionDefinition
const updateContact = {
title: 'Update Contact',
description: 'Update a Salesforce Contact',
input: {
schema: sdk.z.object({
Id: sdk.z.string().title('ID').describe('The ID of the contact'),
...createContactInputSchema.partial().shape,
}),
},
output: {
schema: recordResultSchema,
},
} satisfies sdk.ActionDefinition
const searchContacts = {
title: 'Search Contacts',
description: 'Search Salesforce Contacts',
input: {
schema: sdk.z.object({
Id: sdk.z.string().optional().title('ID').describe('The ID of the contact'),
Name: sdk.z.string().optional().title('Name').describe('The first name of the contact (e.g. John)'),
Email: sdk.z
.string()
.email()
.optional()
.title('Email')
.describe('The email address of the contact (e.g. john.doe@example.com)'),
}),
},
output: {
schema: searchOutputSchema,
},
} satisfies sdk.ActionDefinition
export const contactActionDefinitions = {
createContact,
searchContacts,
updateContact,
} satisfies sdk.IntegrationDefinitionProps['actions']
@@ -0,0 +1,12 @@
import * as sdk from '@botpress/sdk'
import { apiActionDefinitions } from './api-actions'
import { caseActionDefinitions } from './case-actions'
import { contactActionDefinitions } from './contact-actions'
import { leadActionDefinitions } from './lead-actions'
export const actionDefinitions = {
...contactActionDefinitions,
...leadActionDefinitions,
...apiActionDefinitions,
...caseActionDefinitions,
} satisfies sdk.IntegrationDefinitionProps['actions']
@@ -0,0 +1,77 @@
import * as sdk from '@botpress/sdk'
import { recordResultSchema, searchOutputSchema } from './common-schemas'
export const createLeadInputSchema = sdk.z.object({
FirstName: sdk.z.string().title('First Name').describe('The first name of the lead (e.g. John)'),
LastName: sdk.z.string().title('Last Name').describe('The last name of the lead (e.g. Doe)'),
Email: sdk.z.string().email().title('Email').describe('The email address of the lead'),
Company: sdk.z.string().title('Company').describe('The company of the lead (e.g. Acme Inc.)'),
Phone: sdk.z.string().optional().title('Phone').describe('The phone number of the lead'),
Title: sdk.z.string().optional().title('Title').describe('The title of the lead'),
Description: sdk.z.string().optional().title('Description').describe('The description of the lead'),
customFields: sdk.z
.string()
.displayAs<any>({
id: 'text',
params: {
allowDynamicVariable: true,
growVertically: true,
multiLine: true,
resizable: true,
},
})
.optional()
.title('Custom Fields')
.describe('Additional fields in JSON format'),
})
const createLead = {
title: 'Create Lead',
description: 'Create a Salesforce Lead',
input: {
schema: createLeadInputSchema,
},
output: {
schema: recordResultSchema,
},
} satisfies sdk.ActionDefinition
const updateLead = {
title: 'Update Lead',
description: 'Update a Salesforce Lead',
input: {
schema: sdk.z.object({
Id: sdk.z.string().title('ID').describe('The ID of the lead'),
...createLeadInputSchema.partial().shape,
}),
},
output: {
schema: recordResultSchema,
},
} satisfies sdk.ActionDefinition
const searchLeads = {
title: 'Search Leads',
description: 'Search Salesforce Leads',
input: {
schema: sdk.z.object({
Id: sdk.z.string().optional().title('ID').describe('The ID of the lead (e.g., leadId1)'),
Name: sdk.z.string().optional().title('Name').describe('The name of the lead (e.g., John Doe)'),
Email: sdk.z
.string()
.email()
.optional()
.title('Email')
.describe('The email address of the lead (e.g., john.doe@example.com)'),
}),
},
output: {
schema: searchOutputSchema,
},
} satisfies sdk.ActionDefinition
export const leadActionDefinitions = {
createLead,
updateLead,
searchLeads,
} satisfies sdk.IntegrationDefinitionProps['actions']
+58
View File
@@ -0,0 +1,58 @@
The Salesforce integration allows you to search, create, update, and delete Salesforce objects (contacts, leads, cases) and make direct API requests.
[![image](https://i.imgur.com/v5l26vi.png)](https://youtu.be/fPORGBUJmG0?si=uZLh40GSFbmGpPZE)
## Integration Setup
1. Install the Integration
2. Click the **Authorize** or **Connect with OAuth** button to authorize with Salesforce and complete the installation process.
3. To use Salesforce's Sandbox environment instead, select the **Sandbox** configuration type before connecting.
## How-To
[![image](https://i.imgur.com/UcgcWJf.png)](https://youtu.be/lszZsLIXNf8?si=Vm20mCzUQyLiOoQm)
## Make API request
1. **Make API Request** action acting as a proxy allowing users to make request to the Salesforce API.
2. Pass valid HTTP method, URL path ("yourinstance.salesforce.com/services/data/v54.0/PATH"), and additional request data if needed.
## Search Contacts
1. In Studio, add the **Search Contacts** card to your flow.
2. Pass at least one search criteria in the card input.
3. You can store the result of the action in a variable.
## Create Contact
1. In Studio, add the **Create Contact** card to your flow.
2. Pass the contact information in the card input. `First Name`, `Last Name`, `Email` fields are required.
3. You can pass custom fields in JSON format
4. You can store the `Id` of the created contact in a variable.
## Update Contact
1. In Studio, add the **Update Contact** card to your flow.
2. Pass the `Id` of the contact to be updated and the field that need to be updated
3. You can pass custom fields in JSON format
4. You can store the `Id` of the updated contact in a variable.
## Search Leads
1. In Studio, add the **Search Leads** card to your flow.
2. Pass at least one search criteria in the card input.
3. You can store the result of the action in a variable.
## Create Leads
1. In Studio, add the **Create Leads** card to your flow.
2. Pass the contact information in the card input. `First Name`, `Last Name`, `Email`, `Company` fields are required.
3. You can pass custom fields in JSON format
4. You can store the `Id` of the created contact in a variable.
## Update Leads
1. In Studio, add the **Update Leads** card to your flow.
2. Pass the `Id` of the contact to be updated and the field that need to be updated
3. You can pass custom fields in JSON format
4. You can store the `Id` of the updated contact in a variable.
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 29 KiB

@@ -0,0 +1,58 @@
import { IntegrationDefinition, z } from '@botpress/sdk'
import { actionDefinitions } from './definitions'
export default new IntegrationDefinition({
name: 'salesforce',
title: 'Salesforce',
version: '1.0.3',
readme: 'hub.md',
icon: 'icon.svg',
description: 'Salesforce integration allows you to create, search, update and delete a variety of Salesforce objects',
configuration: {
identifier: {
linkTemplateScript: 'linkTemplate.vrl',
},
schema: z.object({}),
},
configurations: {
sfsandbox: {
title: 'Sandbox',
description: 'Use Salesforce sandbox environment (test.salesforce.com)',
schema: z.object({}),
identifier: {
linkTemplateScript: 'linkTemplate.vrl',
},
},
},
states: {
credentials: {
type: 'integration',
schema: z.object({
isSandbox: z.boolean().title('Is Sandbox').describe('Whether this is a Salesforce sandbox environment'),
accessToken: z.string().title('Access Token').describe('OAuth access token for Salesforce API calls'),
instanceUrl: z
.string()
.title('Instance URL')
.describe('Salesforce instance URL (e.g. https://yourorg.salesforce.com)'),
refreshToken: z
.string()
.optional()
.title('Refresh Token')
.describe('OAuth refresh token used to obtain new access tokens'),
}),
},
},
secrets: {
CONSUMER_KEY: {
description: 'Consumer key of the Salesforce app',
},
CONSUMER_SECRET: {
description: 'Consumer secret of the Salesforce app',
},
},
actions: actionDefinitions,
attributes: {
category: 'CRM & Sales',
repo: 'botpress',
},
})
+4
View File
@@ -0,0 +1,4 @@
webhookId = to_string!(.webhookId)
webhookUrl = to_string!(.webhookUrl)
"{{ webhookUrl }}/oauth/wizard/start?state={{ webhookId }}"
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@botpresshub/salesforce",
"description": "Salesforce 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/cli": "workspace:*",
"@botpress/client": "workspace:*",
"@botpress/common": "workspace:*",
"@botpress/sdk": "workspace:*",
"axios": "^1.7.7",
"jsforce": "^1.11.1",
"preact": "^10.26.6",
"preact-render-to-string": "^6.5.13"
},
"devDependencies": {
"@types/jsforce": "^1.11.5",
"@types/node": "^22.16.4"
}
}
@@ -0,0 +1,65 @@
import { isAxiosError } from 'axios'
import { makeRequest } from '../misc/utils/api-utils'
import { getSfCredentials } from '../misc/utils/bp-utils'
import { handleError } from '../misc/utils/error-utils'
import { refreshSfToken } from '../misc/utils/sf-utils'
import * as bp from '.botpress'
export const makeApiRequest: bp.IntegrationProps['actions']['makeApiRequest'] = async (props) => {
const { input, client, ctx, logger } = props
const sfCredentials = await getSfCredentials(client, ctx.integrationId)
const url = `${sfCredentials.instanceUrl}/services/data/v54.0/${input.path}`
try {
const res = await makeRequest(url, input, sfCredentials.accessToken)
return {
success: true,
status: res.status,
body: res.data,
}
} catch (e) {
const errorMsg = "'Make API request' error:"
if (isAxiosError(e)) {
const status = e.response?.status
logger
.forBot()
.warn(
`Salesforce API request failed with HTTP status ${status ?? 'unknown'} (axios code: ${e.code ?? 'unknown'})`
)
if (status === 401) {
try {
logger.forBot().info('Salesforce access token expired, attempting to refresh it')
await refreshSfToken(client, ctx, logger)
const newSfCredentials = await getSfCredentials(client, ctx.integrationId)
const res = await makeRequest(url, input, newSfCredentials.accessToken)
return {
success: true,
status: res.status,
body: res.data,
}
} catch (e) {
return handleError(errorMsg, e, logger)
}
}
logger
.forBot()
.warn(
`Salesforce API request error is not an expired access token, skipping token refresh (HTTP ${status ?? 'unknown'})`
)
}
return handleError(errorMsg, e, logger)
}
}
export const ApiActions = {
makeApiRequest,
}
@@ -0,0 +1,23 @@
import { SalesforceObject } from 'src/misc/types'
import { createSalesforceRecord } from './generic/create-salesforce-record'
import { fetchSalesforceRecords } from './generic/fetch-salesforce-records'
import { updateSalesforceRecord } from './generic/update-salesforce-record'
import * as bp from '.botpress'
export const createCase: bp.IntegrationProps['actions']['createCase'] = async (props) => {
return await createSalesforceRecord(SalesforceObject.Case, props)
}
export const updateCase: bp.IntegrationProps['actions']['updateCase'] = async (props) => {
return await updateSalesforceRecord(SalesforceObject.Case, props)
}
export const searchCases: bp.IntegrationProps['actions']['searchCases'] = async (props) => {
return await fetchSalesforceRecords(SalesforceObject.Case, props)
}
export const CaseActions = {
createCase,
updateCase,
searchCases,
}
@@ -0,0 +1,23 @@
import { SalesforceObject } from 'src/misc/types'
import { createSalesforceRecord } from './generic/create-salesforce-record'
import { fetchSalesforceRecords } from './generic/fetch-salesforce-records'
import { updateSalesforceRecord } from './generic/update-salesforce-record'
import * as bp from '.botpress'
export const createContact: bp.IntegrationProps['actions']['createContact'] = async (props) => {
return await createSalesforceRecord(SalesforceObject.Contact, props)
}
export const updateContact: bp.IntegrationProps['actions']['updateContact'] = async (props) => {
return await updateSalesforceRecord(SalesforceObject.Contact, props)
}
export const searchContacts: bp.IntegrationProps['actions']['searchContacts'] = async (props) => {
return await fetchSalesforceRecords(SalesforceObject.Contact, props)
}
export const ContactActions = {
createContact,
updateContact,
searchContacts,
}
@@ -0,0 +1,32 @@
import { RecordResult } from '../../../definitions/common-schemas'
import { SalesforceObject } from '../../misc/types'
import { handleError } from '../../misc/utils/error-utils'
import { getConnection, getRequestPayload } from '../../misc/utils/sf-utils'
import * as bp from '.botpress'
export const createSalesforceRecord = async (
objectType: SalesforceObject,
props: bp.ActionProps['createContact'] | bp.ActionProps['createLead'] | bp.ActionProps['createCase']
): Promise<RecordResult> => {
const { client, ctx, input, logger } = props
const errorMsg = `'Create ${objectType}' error:`
try {
const payload = getRequestPayload(input)
logger.forBot().info(`Attempting to create a ${objectType} from from ${JSON.stringify(payload)}`)
const connection = await getConnection(client, ctx, logger)
const response = await connection.sobject(objectType).create(payload)
if (!response.success) {
return handleError(errorMsg, response.errors, logger)
}
logger.forBot().info(`Successfully created ${objectType} with id ${response.id}`)
return response
} catch (error) {
return handleError(errorMsg, error, logger)
}
}
@@ -0,0 +1,29 @@
import { SalesforceObject, QueryOutput } from '../../misc/types'
import { handleError } from '../../misc/utils/error-utils'
import { getSearchQuery } from '../../misc/utils/query-utils'
import { getConnection } from '../../misc/utils/sf-utils'
import * as bp from '.botpress'
export const fetchSalesforceRecords = async (
objectType: SalesforceObject,
props: bp.ActionProps['searchCases'] | bp.ActionProps['searchContacts'] | bp.ActionProps['searchLeads']
): Promise<QueryOutput> => {
const { client, ctx, input, logger } = props
logger.forBot().info(`Attempting to search ${objectType} from ${JSON.stringify(input)}`)
const errorMsg = `'Search ${objectType}' error:`
try {
const connection = await getConnection(client, ctx, logger)
const objectMeta = await connection.sobject(objectType).describe()
const objectFields = objectMeta.fields.map((field) => field.name).join(', ')
const searchQuery = getSearchQuery(objectType, input, objectFields)
const response = await connection.query(searchQuery)
logger.forBot().info(`Successfully searched ${objectType} from data ${JSON.stringify(input)}`)
return { success: true, records: response.records }
} catch (error) {
return handleError(errorMsg, error, logger)
}
}
@@ -0,0 +1,32 @@
import { RecordResult } from '../../../definitions/common-schemas'
import { SalesforceObject } from '../../misc/types'
import { handleError } from '../../misc/utils/error-utils'
import { getConnection, getRequestPayload } from '../../misc/utils/sf-utils'
import * as bp from '.botpress'
export const updateSalesforceRecord = async (
objectType: SalesforceObject,
props: bp.ActionProps['updateContact'] | bp.ActionProps['updateLead'] | bp.ActionProps['updateCase']
): Promise<RecordResult> => {
const { client, ctx, input, logger } = props
const errorMsg = `'Update ${objectType}' error:`
try {
const payload = getRequestPayload(input)
logger.forBot().info(`Attempting to update a ${objectType} from ${JSON.stringify(payload)}`)
const connection = await getConnection(client, ctx, logger)
const response = await connection.sobject(objectType).update(payload)
if (!response.success) {
return handleError(errorMsg, response.errors, logger)
}
logger.forBot().info(`Successfully updated contact with data ${JSON.stringify(input)}`)
return response
} catch (error) {
return handleError(errorMsg, error, logger)
}
}
@@ -0,0 +1,12 @@
import { ApiActions } from './api'
import { CaseActions } from './cases'
import { ContactActions } from './contacts'
import { LeadActions } from './leads'
import * as bp from '.botpress'
export const actions = {
...ContactActions,
...LeadActions,
...ApiActions,
...CaseActions,
} satisfies bp.IntegrationProps['actions']
@@ -0,0 +1,23 @@
import { SalesforceObject } from 'src/misc/types'
import { createSalesforceRecord } from './generic/create-salesforce-record'
import { fetchSalesforceRecords } from './generic/fetch-salesforce-records'
import { updateSalesforceRecord } from './generic/update-salesforce-record'
import * as bp from '.botpress'
export const createLead: bp.IntegrationProps['actions']['createLead'] = async (props) => {
return await createSalesforceRecord(SalesforceObject.Lead, props)
}
export const updateLead: bp.IntegrationProps['actions']['updateLead'] = async (props) => {
return await updateSalesforceRecord(SalesforceObject.Lead, props)
}
export const searchLeads: bp.IntegrationProps['actions']['searchLeads'] = async (props) => {
return await fetchSalesforceRecords(SalesforceObject.Lead, props)
}
export const LeadActions = {
createLead,
updateLead,
searchLeads,
}
+86
View File
@@ -0,0 +1,86 @@
import * as oauthWizard from '@botpress/common/src/oauth-wizard'
import { Connection, OAuth2 } from 'jsforce'
import { getEnvironmentUrl } from './misc/utils/sf-utils'
import * as bp from '.botpress'
const _startStep: oauthWizard.WizardStepHandler<bp.HandlerProps> = async ({ ctx, responses }) => {
const oauthCallbackUrl = oauthWizard.getWizardStepUrl('oauth-callback', ctx).toString()
const loginUrl = getEnvironmentUrl(ctx)
const url =
`${loginUrl}/services/oauth2/authorize` +
'?response_type=code' +
`&client_id=${bp.secrets.CONSUMER_KEY}` +
`&redirect_uri=${encodeURIComponent(oauthCallbackUrl)}` +
`&state=${ctx.webhookId}`
return responses.redirectToExternalUrl(url)
}
const _oauthCallbackStep: oauthWizard.WizardStepHandler<bp.HandlerProps> = async ({
ctx,
client,
query,
responses,
}) => {
const error = query.get('error')
if (error) {
const description = query.get('error_description') ?? ''
return responses.endWizard({ success: false, errorMessage: `OAuth error: ${error} - ${description}` })
}
const code = query.get('code')
if (!code) {
return responses.endWizard({ success: false, errorMessage: 'Authorization code not present in OAuth callback' })
}
const oauthCallbackUrl = oauthWizard.getWizardStepUrl('oauth-callback', ctx).toString()
const oauth2 = new OAuth2({
clientId: bp.secrets.CONSUMER_KEY,
clientSecret: bp.secrets.CONSUMER_SECRET,
redirectUri: oauthCallbackUrl,
loginUrl: getEnvironmentUrl(ctx),
})
const connection = new Connection({ oauth2 })
await connection.authorize(code)
const { accessToken, instanceUrl, refreshToken } = connection
if (!refreshToken) {
return responses.endWizard({ success: false, errorMessage: 'No refresh token provided' })
}
await client.setState({
type: 'integration',
name: 'credentials',
id: ctx.integrationId,
payload: {
isSandbox: ctx.configurationType === 'sfsandbox',
accessToken,
instanceUrl,
refreshToken,
},
})
await client.configureIntegration({ identifier: ctx.webhookId })
return responses.endWizard({ success: true })
}
export const handler: bp.IntegrationProps['handler'] = async (props) => {
const { req } = props
if (!oauthWizard.isOAuthWizardUrl(req.path)) {
console.debug('Not an OAuth wizard request, handler will not process the request.')
return
}
try {
return await new oauthWizard.OAuthWizardBuilder(props)
.addStep({ id: 'start', handler: _startStep })
.addStep({ id: 'oauth-callback', handler: _oauthCallbackStep })
.build()
.handleRequest()
} catch (thrown: unknown) {
const errMsg = thrown instanceof Error ? thrown.message : String(thrown)
return oauthWizard.generateRedirection(oauthWizard.getInterstitialUrl(false, errMsg))
}
}
+27
View File
@@ -0,0 +1,27 @@
import { actions } from './actions'
import { handler } from './handler'
import { getSfCredentials } from './misc/utils/bp-utils'
import * as bp from '.botpress'
export default new bp.Integration({
register: async (props) => {
const { client, ctx, logger } = props
const credentials = await getSfCredentials(client, ctx.integrationId)
if ((ctx.configurationType === 'sfsandbox') !== credentials.isSandbox) {
await client.setState({
type: 'integration',
name: 'credentials',
id: ctx.integrationId,
payload: null,
})
logger.forBot().info('Salesforce environment has changed, please login again')
}
},
unregister: async () => {},
actions,
channels: {},
handler,
})
+11
View File
@@ -0,0 +1,11 @@
export enum SalesforceObject {
Contact = 'Contact',
Case = 'Case',
Lead = 'Lead',
}
export type QueryOutput =
| {
success: true
records: any[]
}
| { success: false; error: string }
@@ -0,0 +1,15 @@
import axios, { AxiosResponse } from 'axios'
import { Input } from '../../../.botpress/implementation/typings/actions/makeApiRequest/input'
export const makeRequest = async (url: string, input: Input, accessToken: string): Promise<AxiosResponse> => {
return axios({
method: input.method,
url,
data: input.requestBody ? JSON.parse(input.requestBody) : {},
params: input.params ? JSON.parse(input.params) : {},
headers: {
Authorization: `Bearer ${accessToken}`,
...(input.headers ? JSON.parse(input.headers) : {}),
},
})
}
@@ -0,0 +1,22 @@
import * as sdk from '@botpress/sdk'
import * as bp from '.botpress'
export const getBotpressWebhookUrl = (): string => process.env['BP_WEBHOOK_URL'] ?? ''
export const getSfCredentials = async (client: bp.Client, integrationId: string) => {
try {
const {
state: { payload },
} = await client.getState({
type: 'integration',
name: 'credentials',
id: integrationId,
})
return payload
} catch {
throw new sdk.RuntimeError(
'Salesforce credentials not found. Please log in to your Salesforce account to continue.'
)
}
}
@@ -0,0 +1,90 @@
import { z } from '@botpress/sdk'
import { isAxiosError } from 'axios'
import * as bp from '.botpress'
const _salesforceApiErrorSchema = z.object({
message: z.string().optional(),
errorCode: z.string().optional(),
statusCode: z.string().optional(),
})
type SalesforceApiError = z.infer<typeof _salesforceApiErrorSchema>
const _salesforceOAuthErrorSchema = z.object({
error: z.string().optional(),
error_description: z.string().optional(),
})
type SalesforceOAuthError = z.infer<typeof _salesforceOAuthErrorSchema>
const _parseSalesforceApiError = (err: unknown): SalesforceApiError => {
const result = _salesforceApiErrorSchema.safeParse(err)
return result.success ? result.data : {}
}
const _parseSalesforceOAuthError = (data: unknown): SalesforceOAuthError => {
const result = _salesforceOAuthErrorSchema.safeParse(data)
return result.success ? result.data : {}
}
const _describeSalesforceApiError = (err: SalesforceApiError): string =>
[err.errorCode ?? err.statusCode, err.message].filter(Boolean).join(': ') || 'Unknown Salesforce API error'
/**
* Salesforce surfaces error details in different shapes depending on the API called:
* - REST/SOAP calls (jsforce) throw an Error with `errorCode` and `message` set from the response body.
* - DML results (create/update) return `{ success: false, errors: [...] }` instead of throwing.
* - Raw HTTP calls (axios) reject with an AxiosError whose `response.data` holds the Salesforce error body.
* - The OAuth2 token endpoint returns `{ error, error_description }` instead of the REST error shape.
* This normalizes all of them into one descriptive, human-readable message.
*/
export const describeSalesforceError = (error: unknown): string => {
if (Array.isArray(error)) {
return (
error
.map((err) => (typeof err === 'string' ? err : _describeSalesforceApiError(_parseSalesforceApiError(err))))
.filter(Boolean)
.join('; ') || 'Unknown Salesforce API error'
)
}
if (isAxiosError(error)) {
const data = error.response?.data
const status = error.response?.status
if (Array.isArray(data)) {
return `${describeSalesforceError(data)}${status ? ` (HTTP ${status})` : ''}`
}
if (data && typeof data === 'object') {
const oauthError = _parseSalesforceOAuthError(data)
if (oauthError.error) {
const detail = [oauthError.error, oauthError.error_description].filter(Boolean).join(': ')
return `${detail}${status ? ` (HTTP ${status})` : ''}`
}
return `${_describeSalesforceApiError(_parseSalesforceApiError(data))}${status ? ` (HTTP ${status})` : ''}`
}
return `${error.message}${status ? ` (HTTP ${status})` : ''}`
}
if (error instanceof Error) {
return error.message
}
if (error && typeof error === 'object' && ('errorCode' in error || 'statusCode' in error)) {
return _describeSalesforceApiError(_parseSalesforceApiError(error))
}
return String(error)
}
export const handleError = (errorMsg: string, error: unknown, logger: bp.Logger) => {
const fullErrorMsg = `${errorMsg} ${describeSalesforceError(error)}`
logger.forBot().error(fullErrorMsg)
logger.forBot().debug(JSON.stringify(isAxiosError(error) ? (error.response?.data ?? error.toJSON()) : error))
return {
success: false,
error: fullErrorMsg,
} as const
}
@@ -0,0 +1,35 @@
import { SalesforceObject } from '../types'
import * as bp from '.botpress'
type SearchInput =
| Parameters<bp.IntegrationProps['actions']['searchCases']>['0']['input']
| Parameters<bp.IntegrationProps['actions']['searchContacts']>['0']['input']
| Parameters<bp.IntegrationProps['actions']['searchLeads']>['0']['input']
export const getSearchQuery = (objectType: SalesforceObject, input: SearchInput, objectFields: string): string => {
let query = `SELECT ${objectFields} FROM ${objectType}`
const conditions: string[] = []
const fields = [
{ key: 'Id', operator: '=' },
{ key: 'Name', operator: 'LIKE' },
{ key: 'Email', operator: '=' },
{ key: 'Subject', operator: 'LIKE' },
{ key: 'Description', operator: 'LIKE' },
{ key: 'Status', operator: '=' },
]
fields.forEach(({ key, operator }) => {
if (input[key as keyof SearchInput]) {
const value = operator === 'LIKE' ? `%${input[key as keyof SearchInput]}%` : input[key as keyof SearchInput]
conditions.push(`${key} ${operator} '${value}'`)
}
})
if (conditions.length > 0) {
query += ' WHERE ' + conditions.join(' AND ')
}
return query
}
@@ -0,0 +1,116 @@
import axios from 'axios'
import { OAuth2, Connection } from 'jsforce'
import { getBotpressWebhookUrl, getSfCredentials } from './bp-utils'
import { describeSalesforceError } from './error-utils'
import * as bp from '.botpress'
export const getOAuth2 = (ctx: bp.Context): OAuth2 => {
return new OAuth2({
clientId: bp.secrets.CONSUMER_KEY,
clientSecret: bp.secrets.CONSUMER_SECRET,
redirectUri: `${getBotpressWebhookUrl()}/oauth/wizard/oauth-callback`,
loginUrl: getEnvironmentUrl(ctx),
})
}
export const getConnection = async (client: bp.Client, ctx: bp.Context, logger: bp.Logger): Promise<Connection> => {
let sfCredentials: bp.states.credentials.Credentials['payload']
try {
sfCredentials = await getSfCredentials(client, ctx.integrationId)
} catch (e) {
const errorMsg = `Error fetching Salesforce credentials: ${describeSalesforceError(e)}`
logger.forBot().error(errorMsg)
throw new Error(errorMsg)
}
const { accessToken, instanceUrl, refreshToken, isSandbox } = sfCredentials
const connection = new Connection({
oauth2: getOAuth2(ctx),
instanceUrl,
accessToken,
refreshToken,
})
//When access token is refreshed, update it in the state
connection.on('refresh', (newAccessToken: string) => {
logger.forBot().info('Salesforce access token expired and was refreshed successfully')
client
.setState({
type: 'integration',
name: 'credentials',
id: ctx.integrationId,
payload: {
isSandbox,
accessToken: newAccessToken,
instanceUrl,
refreshToken,
},
})
.catch((thrown: unknown) => {
logger.forBot().error(`Error persisting refreshed Salesforce access token: ${describeSalesforceError(thrown)}`)
})
})
return connection
}
export const refreshSfToken = async (client: bp.Client, ctx: bp.Context, logger: bp.Logger): Promise<void> => {
const url = `${getEnvironmentUrl(ctx)}/services/oauth2/token`
const sfCredentials = await getSfCredentials(client, ctx.integrationId)
if (!sfCredentials.refreshToken) {
const errorMsg = 'No refresh token available. Please re-authenticate with Salesforce.'
logger.forBot().error(errorMsg)
throw new Error(errorMsg)
}
const params = new URLSearchParams()
params.append('grant_type', 'refresh_token')
params.append('client_id', bp.secrets.CONSUMER_KEY)
params.append('client_secret', bp.secrets.CONSUMER_SECRET)
params.append('refresh_token', sfCredentials.refreshToken)
try {
const response = await axios.post(url, params, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
})
await client.setState({
type: 'integration',
name: 'credentials',
id: ctx.integrationId,
payload: {
isSandbox: sfCredentials.isSandbox,
accessToken: response.data.access_token,
instanceUrl: sfCredentials.instanceUrl,
refreshToken: sfCredentials.refreshToken,
},
})
logger.forBot().info('Salesforce access token expired and was refreshed successfully')
} catch (error) {
const errorMsg = `Error refreshing Salesforce token: ${describeSalesforceError(error)}`
logger.forBot().error(errorMsg)
throw new Error(errorMsg)
}
}
export const getRequestPayload = <T extends { customFields?: string }>(input: T): T & Record<string, any> => {
const customFields: Record<string, any> = input.customFields ? JSON.parse(input.customFields) : {}
const payload = {
...input,
...customFields,
}
delete payload.customFields
return payload
}
export const getEnvironmentUrl = (ctx: bp.Context): string => {
return ctx.configurationType === 'sfsandbox' ? 'https://test.salesforce.com' : 'https://login.salesforce.com'
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact",
"paths": { "*": ["./*"] },
"outDir": "dist",
"types": ["preact"]
},
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
}