chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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']
|
||||
@@ -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.
|
||||
|
||||
[](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
|
||||
|
||||
[](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',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
webhookId = to_string!(.webhookId)
|
||||
webhookUrl = to_string!(.webhookUrl)
|
||||
|
||||
"{{ webhookUrl }}/oauth/wizard/start?state={{ webhookId }}"
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist",
|
||||
"types": ["preact"]
|
||||
},
|
||||
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user