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,83 @@
import { z } from '@botpress/sdk'
import { companySchema } from '../../definitions/actions/company'
import { getAuthenticatedHubspotClient, propertiesEntriesToRecord } from '../utils'
import * as bp from '.botpress'
type HubspotClient = Awaited<ReturnType<typeof getAuthenticatedHubspotClient>>
type HsCompany = NonNullable<Awaited<ReturnType<HubspotClient['searchCompany']>>>
type BpCompany = z.infer<typeof companySchema>
const _mapHsCompanyToBpCompany = (hsCompany: HsCompany): BpCompany => ({
id: hsCompany.id,
name: hsCompany.properties['name'] ?? '',
domain: hsCompany.properties['domain'] ?? '',
createdAt: hsCompany.createdAt.toISOString(),
updatedAt: hsCompany.updatedAt.toISOString(),
properties: hsCompany.properties,
})
const _getCompanyPropertyKeys = async (hsClient: HubspotClient) => {
const properties = await hsClient.getAllObjectProperties('companies')
return properties.results.map((property) => property.name)
}
export const searchCompany: bp.IntegrationProps['actions']['searchCompany'] = async ({
client,
ctx,
input,
logger,
}) => {
const hsClient = await getAuthenticatedHubspotClient({ client, ctx, logger })
const propertyKeys = await _getCompanyPropertyKeys(hsClient)
const company = await hsClient.searchCompany({
name: input.name,
domain: input.domain,
propertiesToReturn: propertyKeys,
})
return {
company: company ? _mapHsCompanyToBpCompany(company) : undefined,
}
}
export const getCompany: bp.IntegrationProps['actions']['getCompany'] = async ({ ctx, client, input, logger }) => {
const hsClient = await getAuthenticatedHubspotClient({ ctx, client, logger })
const propertyKeys =
input.propertiesToReturn && input.propertiesToReturn.length > 0
? input.propertiesToReturn
: await _getCompanyPropertyKeys(hsClient)
const company = await hsClient.getCompanyById({
companyId: Number(input.companyId),
propertiesToReturn: propertyKeys,
})
return {
company: _mapHsCompanyToBpCompany(company),
}
}
export const updateCompany: bp.IntegrationProps['actions']['updateCompany'] = async ({
ctx,
client,
input,
logger,
}) => {
const hsClient = await getAuthenticatedHubspotClient({ ctx, client, logger })
const additionalProperties = propertiesEntriesToRecord(input.properties ?? [])
const updatedCompany = await hsClient.updateCompany({
companyId: Number(input.companyId),
additionalProperties,
})
return {
company: {
..._mapHsCompanyToBpCompany(updatedCompany),
name: updatedCompany.properties['name'] ?? undefined,
domain: updatedCompany.properties['domain'] ?? undefined,
},
}
}
+175
View File
@@ -0,0 +1,175 @@
import { z } from '@botpress/sdk'
import { contactSchema } from '../../definitions/actions/contact'
import { buildContactUrl, getAuthenticatedHubspotClient, getOrFetchPortalId, propertiesEntriesToRecord } from '../utils'
import * as bp from '.botpress'
type HubspotClient = Awaited<ReturnType<typeof getAuthenticatedHubspotClient>>
type HsContact = Awaited<ReturnType<HubspotClient['getContact']>>
type BpContact = z.infer<typeof contactSchema>
const _mapHsContactToBpContact = (hsContact: HsContact): BpContact => ({
id: hsContact.id,
properties: hsContact.properties,
email: hsContact.properties['email'] ?? '',
phone: hsContact.properties['phone'] ?? '',
createdAt: hsContact.createdAt.toISOString(),
updatedAt: hsContact.updatedAt.toISOString(),
})
const _getContactPropertyKeys = async (hsClient: HubspotClient) => {
const properties = await hsClient.getAllObjectProperties('contacts')
return properties.results.map((property) => property.name)
}
export const searchContact: bp.IntegrationProps['actions']['searchContact'] = async ({
client,
ctx,
input,
logger,
}) => {
const hsClient = await getAuthenticatedHubspotClient({ ctx, client, logger })
const phoneStr = input.phone ? `phone ${input.phone}` : 'unknown phone'
const emailStr = input.email ? `email ${input.email}` : 'unknown email'
const infosStr = `${phoneStr} and ${emailStr}`
const propertyKeys = input.properties?.length ? input.properties : await _getContactPropertyKeys(hsClient)
logger
.forBot()
.debug(
`Searching for contact with ${infosStr} ${propertyKeys?.length ? `and properties ${propertyKeys?.join(', ')}` : ''}`
)
const contact = await hsClient.searchContact({
phone: input.phone,
email: input.email,
propertiesToReturn: propertyKeys,
})
if (!contact) {
return {
contact: undefined,
}
}
const portalId = await getOrFetchPortalId({ client, ctx, hsClient })
return {
contact: _mapHsContactToBpContact(contact),
url: buildContactUrl({ portalId, contactId: contact.id }),
}
}
export const listContactProperties: bp.IntegrationProps['actions']['listContactProperties'] = async ({
ctx,
client,
logger,
}) => {
const hsClient = await getAuthenticatedHubspotClient({ ctx, client, logger })
try {
const properties = await hsClient.listContactProperties()
return {
properties: properties.map((property) => ({
name: property.name,
label: property.label,
type: property.type,
fieldType: property.fieldType,
groupName: property.groupName,
description: property.description,
...(property.referencedObjectType ? { referencedObjectType: property.referencedObjectType } : {}),
})),
}
} catch (err: unknown) {
logger.forBot().debug(`Contact properties could not be retrieved: ${err}`)
return { properties: [] }
}
}
export const createContact: bp.IntegrationProps['actions']['createContact'] = async ({
ctx,
client,
input,
logger,
}) => {
const hsClient = await getAuthenticatedHubspotClient({ ctx, client, logger })
const { email, phone, owner, companies, tickets, properties } = input
const additionalProperties = propertiesEntriesToRecord(properties ?? [])
const newContact = await hsClient.createContact({
email,
phone,
ownerEmailOrId: owner,
companies,
ticketIds: tickets,
additionalProperties,
})
return {
contact: _mapHsContactToBpContact(newContact),
}
}
export const getContact: bp.IntegrationProps['actions']['getContact'] = async ({ ctx, client, input, logger }) => {
const hsClient = await getAuthenticatedHubspotClient({ ctx, client, logger })
const propertyKeys = await _getContactPropertyKeys(hsClient)
const [contact, portalId] = await Promise.all([
hsClient.getContact({
contactId: input.contactIdOrEmail,
propertiesToReturn: propertyKeys,
}),
getOrFetchPortalId({ client, ctx, hsClient }),
])
return {
contact: _mapHsContactToBpContact(contact),
url: buildContactUrl({ portalId, contactId: contact.id }),
}
}
export const updateContact: bp.IntegrationProps['actions']['updateContact'] = async ({
ctx,
client,
input,
logger,
}) => {
const hsClient = await getAuthenticatedHubspotClient({ ctx, client, logger })
const { contactIdOrEmail, phone, email, properties } = input
const additionalProperties = propertiesEntriesToRecord(properties ?? [])
const updatedContact = await hsClient.updateContact({
contactId: contactIdOrEmail,
additionalProperties,
email,
phone,
})
return {
contact: {
..._mapHsContactToBpContact(updatedContact),
email: updatedContact.properties['email'] ?? undefined,
phone: updatedContact.properties['phone'] ?? undefined,
},
}
}
export const deleteContact: bp.IntegrationProps['actions']['deleteContact'] = async ({
ctx,
client,
input,
logger,
}) => {
const hsClient = await getAuthenticatedHubspotClient({ ctx, client, logger })
await hsClient.deleteContact({
contactId: input.contactId,
})
return {}
}
export const listContacts: bp.IntegrationProps['actions']['listContacts'] = async ({ ctx, client, input, logger }) => {
const hsClient = await getAuthenticatedHubspotClient({ ctx, client, logger })
const propertyKeys = await _getContactPropertyKeys(hsClient)
const { contacts, nextToken } = await hsClient.listContacts({
properties: propertyKeys,
nextToken: input.meta.nextToken,
})
return {
contacts: contacts.map(_mapHsContactToBpContact),
meta: {
nextToken,
},
}
}
+87
View File
@@ -0,0 +1,87 @@
import { z } from '@botpress/sdk'
import { dealSchema } from '../../definitions/actions/deal'
import { getAuthenticatedHubspotClient, propertiesEntriesToRecord } from '../utils'
import * as bp from '.botpress'
type HubspotClient = Awaited<ReturnType<typeof getAuthenticatedHubspotClient>>
type HsDeal = Awaited<ReturnType<HubspotClient['getDealById']>>
type BpDeal = z.infer<typeof dealSchema>
const _mapHsDealToBpDeal = (hsDeal: HsDeal): BpDeal => ({
id: hsDeal.id,
name: hsDeal.properties.dealname ?? '',
createdAt: hsDeal.createdAt.toISOString(),
updatedAt: hsDeal.updatedAt.toISOString(),
properties: hsDeal.properties,
})
const _getDealPropertyKeys = async (hsClient: HubspotClient) => {
const properties = await hsClient.getAllObjectProperties('deals')
return properties.results.map((property) => property.name)
}
export const searchDeal: bp.IntegrationProps['actions']['searchDeal'] = async ({ client, ctx, input, logger }) => {
const hsClient = await getAuthenticatedHubspotClient({ client, ctx, logger })
const propertyKeys = await _getDealPropertyKeys(hsClient)
const deal = await hsClient.searchDeal({ name: input.name, propertiesToReturn: propertyKeys })
if (!deal) {
return {
deal: undefined,
}
}
return {
deal: _mapHsDealToBpDeal(deal),
}
}
export const createDeal: bp.IntegrationProps['actions']['createDeal'] = async ({ client, ctx, input, logger }) => {
const hsClient = await getAuthenticatedHubspotClient({ client, ctx, logger })
const deal = await hsClient.createDeal({
name: input.name,
properties: propertiesEntriesToRecord(input.properties ?? []),
})
return {
deal: _mapHsDealToBpDeal(deal),
}
}
export const getDeal: bp.IntegrationProps['actions']['getDeal'] = async ({ client, ctx, input, logger }) => {
const hsClient = await getAuthenticatedHubspotClient({ client, ctx, logger })
const propertyKeys = await _getDealPropertyKeys(hsClient)
const deal = await hsClient.getDealById({ dealId: input.dealId, propertiesToReturn: propertyKeys })
return {
deal: _mapHsDealToBpDeal(deal),
}
}
export const updateDeal: bp.IntegrationProps['actions']['updateDeal'] = async ({ client, ctx, input, logger }) => {
const hsClient = await getAuthenticatedHubspotClient({ client, ctx, logger })
const deal = await hsClient.updateDealById({
dealId: input.dealId,
name: input.name,
properties: propertiesEntriesToRecord(input.properties ?? []),
})
return {
deal: {
..._mapHsDealToBpDeal(deal),
name: deal.properties.dealname ?? undefined,
},
}
}
export const deleteDeal: bp.IntegrationProps['actions']['deleteDeal'] = async ({ client, ctx, input, logger }) => {
const hsClient = await getAuthenticatedHubspotClient({ client, ctx, logger })
await hsClient.deleteDealById({ dealId: input.dealId })
return {}
}
+13
View File
@@ -0,0 +1,13 @@
import { getAuthenticatedHubspotClient } from '../utils'
import * as bp from '.botpress'
export const getFileUrl: bp.IntegrationProps['actions']['getFileUrl'] = async ({ ctx, client, input, logger }) => {
const hsClient = await getAuthenticatedHubspotClient({ ctx, client, logger })
try {
const url = await hsClient.getFileUrl({ fileName: input.fileName })
return { url }
} catch (err: unknown) {
logger.forBot().debug(`File ${input.fileName} URL could not be retrieved: ${err}`)
return { url: undefined }
}
}
+138
View File
@@ -0,0 +1,138 @@
import { RuntimeError } from '@botpress/sdk'
import { randomUUID } from 'crypto'
import { getHitlClient } from '../hitl/client'
import * as bp from '.botpress'
export const createUser: bp.IntegrationProps['actions']['createUser'] = async ({ client, input, logger }) => {
try {
const { name = 'Unknown', email, pictureUrl } = input
if (!email || !email.trim()) {
throw new RuntimeError('An identifier (email or phone number) is required for HITL user creation.')
}
const trimmedIdentifier = email.trim()
const isEmail = trimmedIdentifier.includes('@')
const contactType = isEmail ? ('email' as const) : ('phone' as const)
const userTags: Record<string, string> = { contactType }
if (isEmail) {
userTags.email = trimmedIdentifier
} else {
userTags.phoneNumber = trimmedIdentifier
}
const { user: botpressUser } = await client.getOrCreateUser({
name,
pictureUrl,
tags: userTags,
})
await client.setState({
id: botpressUser.id,
type: 'user',
name: 'hitlUserInfo',
payload: {
name,
contactIdentifier: trimmedIdentifier,
contactType,
},
})
logger.forBot().debug(`createUser: created/found user ${botpressUser.id} (${contactType})`)
return { userId: botpressUser.id }
} catch (thrown: unknown) {
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
throw new RuntimeError(error.message)
}
}
export const startHitl: bp.IntegrationProps['actions']['startHitl'] = async ({ ctx, client, logger, input }) => {
const hitlClient = getHitlClient(ctx, client, logger)
try {
const { userId, title, description = 'No description available' } = input
const {
state: { payload: userInfo },
} = await client.getState({ id: userId, name: 'hitlUserInfo', type: 'user' })
if (!userInfo?.contactIdentifier) {
throw new RuntimeError('User identifier not found. Please ensure the user is created with createUser first.')
}
const channelState = await client
.getState({ id: ctx.integrationId, name: 'hitlConfig', type: 'integration' })
.catch(() => null)
const channelInfo = channelState?.state?.payload
if (!channelInfo?.channelId) {
throw new RuntimeError(
'HITL channel not configured. Please make sure you enabled/configured HITL for this Integration.'
)
}
const { name, contactIdentifier } = userInfo
const { channelId, defaultInboxId, channelAccounts } = channelInfo
const inboxId = input.hitlSession?.inboxId?.length ? input.hitlSession.inboxId : defaultInboxId
const channelAccountId = channelAccounts?.[inboxId]
if (!channelAccountId) {
throw new RuntimeError(
`No channel account found for inbox ${inboxId}. Make sure this inbox was selected during setup.`
)
}
const integrationThreadId = randomUUID()
const result = await hitlClient.createConversation(
channelId,
channelAccountId,
integrationThreadId,
name,
contactIdentifier,
title || 'New Support Request',
description
)
const hubspotConversationId = result.data.conversationsThreadId
const { conversation } = await client.createConversation({
channel: 'hitl',
tags: {
id: hubspotConversationId,
userId,
integrationThreadId,
inboxId,
},
})
return { conversationId: conversation.id }
} catch (thrown: unknown) {
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
logger.forBot().error(`startHitl error: ${error.message}`)
throw new RuntimeError(error.message)
}
}
export const stopHitl: bp.IntegrationProps['actions']['stopHitl'] = async ({ ctx, client, input, logger }) => {
const { conversationId } = input
try {
const { conversation } = await client.getConversation({ id: conversationId })
const threadId = conversation.tags.id
if (!threadId) {
logger.forBot().warn(`stopHitl: no HubSpot thread ID on conversation ${conversationId} — skipping close`)
return {}
}
const hitlClient = getHitlClient(ctx, client, logger)
await hitlClient.closeThread(threadId)
logger.forBot().info(`stopHitl: closed HubSpot thread ${threadId}`)
} catch (thrown: unknown) {
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
logger.forBot().error(`stopHitl error: ${error.message}`)
throw new RuntimeError(error.message)
}
return {}
}
+22
View File
@@ -0,0 +1,22 @@
import * as companyActions from './company'
import * as contactActions from './contact'
import * as dealActions from './deal'
import * as fileActions from './file'
import { createUser, startHitl, stopHitl } from './hitl'
import * as leadActions from './lead'
import * as ownerActions from './owner'
import * as ticketActions from './ticket'
import * as bp from '.botpress'
export default {
...contactActions,
...dealActions,
...ticketActions,
...leadActions,
...companyActions,
...ownerActions,
...fileActions,
createUser,
startHitl,
stopHitl,
} as const satisfies bp.IntegrationProps['actions']
+88
View File
@@ -0,0 +1,88 @@
import { z } from '@botpress/sdk'
import { leadSchema } from '../../definitions/actions/lead'
import { getAuthenticatedHubspotClient, propertiesEntriesToRecord } from '../utils'
import * as bp from '.botpress'
type HubspotClient = Awaited<ReturnType<typeof getAuthenticatedHubspotClient>>
type HsLead = Awaited<ReturnType<HubspotClient['getLeadById']>>
type BpLead = z.infer<typeof leadSchema>
const _mapHsLeadToBpLead = (hsLead: HsLead): BpLead => ({
id: hsLead.id,
name: hsLead.properties.hs_lead_name ?? '',
createdAt: hsLead.createdAt.toISOString(),
updatedAt: hsLead.updatedAt.toISOString(),
properties: hsLead.properties,
})
const _getLeadPropertyKeys = async (hsClient: HubspotClient) => {
const properties = await hsClient.getAllObjectProperties('leads')
return properties.results.map((property) => property.name)
}
export const searchLead: bp.IntegrationProps['actions']['searchLead'] = async ({ client, ctx, input, logger }) => {
const hsClient = await getAuthenticatedHubspotClient({ client, ctx, logger })
const propertyKeys = await _getLeadPropertyKeys(hsClient)
const lead = await hsClient.searchLead({ name: input.name, propertiesToReturn: propertyKeys })
if (!lead) {
return {
lead: undefined,
}
}
return {
lead: _mapHsLeadToBpLead(lead),
}
}
export const createLead: bp.IntegrationProps['actions']['createLead'] = async ({ client, ctx, input, logger }) => {
const hsClient = await getAuthenticatedHubspotClient({ client, ctx, logger })
const lead = await hsClient.createLead({
name: input.name,
contactEmailOrId: input.contact,
properties: propertiesEntriesToRecord(input.properties ?? []),
})
return {
lead: _mapHsLeadToBpLead(lead),
}
}
export const getLead: bp.IntegrationProps['actions']['getLead'] = async ({ client, ctx, input, logger }) => {
const hsClient = await getAuthenticatedHubspotClient({ client, ctx, logger })
const propertyKeys = await _getLeadPropertyKeys(hsClient)
const lead = await hsClient.getLeadById({ leadId: input.leadId, propertiesToReturn: propertyKeys })
return {
lead: _mapHsLeadToBpLead(lead),
}
}
export const updateLead: bp.IntegrationProps['actions']['updateLead'] = async ({ client, ctx, input, logger }) => {
const hsClient = await getAuthenticatedHubspotClient({ client, ctx, logger })
const lead = await hsClient.updateLead({
leadId: input.leadId,
name: input.name,
properties: propertiesEntriesToRecord(input.properties ?? []),
})
return {
lead: {
..._mapHsLeadToBpLead(lead),
name: lead.properties.hs_lead_name ?? undefined,
},
}
}
export const deleteLead: bp.IntegrationProps['actions']['deleteLead'] = async ({ client, ctx, input, logger }) => {
const hsClient = await getAuthenticatedHubspotClient({ client, ctx, logger })
await hsClient.deleteLead({ leadId: input.leadId })
return {}
}
+25
View File
@@ -0,0 +1,25 @@
import { getAuthenticatedHubspotClient } from '../utils'
import * as bp from '.botpress'
export const getOwner: bp.IntegrationProps['actions']['getOwner'] = async ({ ctx, client, input, logger }) => {
const hsClient = await getAuthenticatedHubspotClient({ ctx, client, logger })
try {
const owner = await hsClient.getOwnerById({ ownerId: input.ownerId })
return {
owner: {
id: owner.id,
email: owner.email,
firstName: owner.firstName,
lastName: owner.lastName,
userId: owner.userId,
type: owner.type,
archived: owner.archived,
createdAt: owner.createdAt.toISOString(),
updatedAt: owner.updatedAt.toISOString(),
},
}
} catch (err: unknown) {
logger.forBot().debug(`Owner ${input.ownerId} could not be retrieved: ${err}`)
return { owner: undefined }
}
}
@@ -0,0 +1,40 @@
import { z } from '@botpress/sdk'
import { ticketSchema } from '../../definitions/actions/ticket'
import { getAuthenticatedHubspotClient, propertiesEntriesToRecord } from '../utils'
import * as bp from '.botpress'
type HubspotClient = Awaited<ReturnType<typeof getAuthenticatedHubspotClient>>
type HsTicket = Awaited<ReturnType<HubspotClient['createTicket']>>
type BpTicket = z.infer<typeof ticketSchema>
const _mapHsTicketToBpTicket = (hsTicket: HsTicket): BpTicket => ({
id: hsTicket.id,
subject: hsTicket.properties.subject ?? '',
category: hsTicket.properties.hs_ticket_category ?? '',
description: hsTicket.properties.content ?? '',
priority: hsTicket.properties.hs_ticket_priority ?? '',
source: hsTicket.properties.source_type ?? '',
properties: hsTicket.properties,
})
export const createTicket: bp.IntegrationProps['actions']['createTicket'] = async ({ client, ctx, input, logger }) => {
const hsClient = await getAuthenticatedHubspotClient({ client, ctx, logger })
const newTicket = await hsClient.createTicket({
subject: input.subject,
category: input.category,
source: input.source,
description: input.description,
additionalProperties: propertiesEntriesToRecord(input.properties ?? []),
pipelineNameOrId: input.pipeline,
pipelineStageNameOrId: input.pipelineStage,
priority: input.priority,
ticketOwnerEmailOrId: input.ticketOwner,
requesterEmailOrId: input.requester,
companyIdOrNameOrDomain: input.company,
})
return {
ticket: _mapHsTicketToBpTicket(newTicket),
}
}
+122
View File
@@ -0,0 +1,122 @@
import { RuntimeError, isApiError } from '@botpress/sdk'
import { Client as OfficialHubspotClient } from '@hubspot/api-client'
import { getEnvironment, useDeskOAuth } from './utils'
import * as bp from '.botpress'
const FIVE_MINUTES_IN_SECONDS = 300
const REDIRECT_URI = `${process.env.BP_WEBHOOK_URL}/oauth`
type OAuthCredentials = {
accessToken: string
refreshToken: string
expiresAtSeconds: number
}
const _getExpiresAtFromExpiresIn = (expiresIn: number) => {
const nowSeconds = Date.now() / 1000
return nowSeconds + expiresIn
}
const _getOAuthAppCredentials = (useDesk: boolean) => ({
clientId: useDesk ? bp.secrets.DESK_CLIENT_ID : bp.secrets.CLIENT_ID,
clientSecret: useDesk ? bp.secrets.DESK_CLIENT_SECRET : bp.secrets.CLIENT_SECRET,
})
export const exchangeCodeForOAuthCredentials = async ({ code, useDesk }: { code: string; useDesk: boolean }) => {
const { clientId, clientSecret } = _getOAuthAppCredentials(useDesk)
const hsClient = new OfficialHubspotClient({})
const { refreshToken, accessToken, expiresIn } = await hsClient.oauth.tokensApi.create(
'authorization_code',
code,
REDIRECT_URI,
clientId,
clientSecret
)
return {
refreshToken,
accessToken,
expiresAtSeconds: _getExpiresAtFromExpiresIn(expiresIn),
}
}
export const setOAuthCredentials = async ({
client,
ctx,
credentials,
}: {
client: bp.Client
ctx: bp.Context
credentials: OAuthCredentials
}) => {
await client.setState({
type: 'integration',
name: 'oauthCredentials',
id: ctx.integrationId,
payload: credentials,
})
}
const _getOrRefreshOAuthAccessToken = async ({ client, ctx }: { client: bp.Client; ctx: bp.Context }) => {
const {
state: {
payload: { accessToken, refreshToken, expiresAtSeconds },
},
} = await client
.getState({ type: 'integration', name: 'oauthCredentials', id: ctx.integrationId })
.catch((e: unknown) => {
if (isApiError(e) && e.code === 404) {
throw new RuntimeError('OAuth credentials not found, please reauthorize')
}
throw e
})
const nowSeconds = Date.now() / 1000
if (nowSeconds <= expiresAtSeconds - FIVE_MINUTES_IN_SECONDS) {
return accessToken
}
const environment = await getEnvironment({ client, ctx })
const { clientId, clientSecret } = _getOAuthAppCredentials(useDeskOAuth(environment))
const hsClient = new OfficialHubspotClient({})
const refreshResponse = await hsClient.oauth.tokensApi.create(
'refresh_token',
undefined,
REDIRECT_URI,
clientId,
clientSecret,
refreshToken
)
const newCredentials = {
accessToken: refreshResponse.accessToken,
refreshToken: refreshResponse.refreshToken,
expiresAtSeconds: _getExpiresAtFromExpiresIn(refreshResponse.expiresIn),
}
await setOAuthCredentials({
client,
ctx,
credentials: newCredentials,
})
return newCredentials.accessToken
}
export const getAccessToken = async ({ client, ctx }: { client: bp.Client; ctx: bp.Context }) => {
if (ctx.configurationType === 'manual') {
const { accessToken } = ctx.configuration
if (!accessToken) {
throw new RuntimeError('Access token not found in saved credentials')
}
return accessToken
}
return _getOrRefreshOAuthAccessToken({ client, ctx })
}
export const getClientSecret = async ({ client, ctx }: { client: bp.Client; ctx: bp.Context }) => {
let clientSecret: string | undefined
if (ctx.configurationType === 'manual') {
clientSecret = ctx.configuration.clientSecret
} else {
const environment = await getEnvironment({ client, ctx })
clientSecret = useDeskOAuth(environment) ? bp.secrets.DESK_CLIENT_SECRET : bp.secrets.CLIENT_SECRET
}
return clientSecret?.length ? clientSecret : undefined
}
@@ -0,0 +1,130 @@
import { RuntimeError } from '@botpress/sdk'
import { getHitlClient } from './client'
import * as bp from '.botpress'
type Attachment = {
url: string
name: string
fileUsageType: 'IMAGE' | 'AUDIO' | 'VOICE_RECORDING' | 'STICKER' | 'OTHER'
}
async function _sendMessage(props: bp.AnyMessageProps, text: string, attachment?: Attachment): Promise<void> {
const { ctx, client, logger, conversation } = props
const { userId, integrationThreadId, inboxId } = conversation.tags
if (!userId) {
throw new RuntimeError('Missing userId in conversation tags')
}
if (!integrationThreadId) {
throw new RuntimeError('Missing integrationThreadId in conversation tags')
}
if (!inboxId) {
throw new RuntimeError('Missing inboxId in conversation tags')
}
const {
state: { payload: userInfo },
} = await client.getState({ id: userId, name: 'hitlUserInfo', type: 'user' })
if (!userInfo?.contactIdentifier) {
throw new RuntimeError('User identifier not found in hitlUserInfo state')
}
const {
state: { payload: channelInfo },
} = await client.getState({ id: ctx.integrationId, name: 'hitlConfig', type: 'integration' })
const channelAccountId = channelInfo?.channelAccounts?.[inboxId]
if (!channelAccountId) {
throw new RuntimeError(`No channel account found for inbox ${inboxId}`)
}
await getHitlClient(ctx, client, logger).sendMessage(
text,
userInfo.name,
userInfo.contactIdentifier,
integrationThreadId,
channelInfo.channelId,
channelAccountId,
attachment
)
}
export const channels: bp.IntegrationProps['channels'] = {
hitl: {
messages: {
text: async (props) => {
await _sendMessage(props, props.payload.text)
},
image: async (props) => {
await _sendMessage(props, '', {
url: props.payload.imageUrl,
name: props.payload.title ?? 'image',
fileUsageType: 'IMAGE',
})
},
file: async (props) => {
await _sendMessage(props, '', {
url: props.payload.fileUrl,
name: props.payload.title ?? 'file',
fileUsageType: 'OTHER',
})
},
audio: async (props) => {
await _sendMessage(props, '', {
url: props.payload.audioUrl,
name: props.payload.title ?? 'audio',
fileUsageType: 'AUDIO',
})
},
video: async (props) => {
await _sendMessage(props, '', {
url: props.payload.videoUrl,
name: props.payload.title ?? 'video',
fileUsageType: 'OTHER',
})
},
bloc: async (props) => {
for (const item of props.payload.items) {
switch (item.type) {
case 'text':
case 'markdown':
await _sendMessage(props, item.type === 'text' ? item.payload.text : item.payload.markdown)
break
case 'image':
await _sendMessage(props, '', {
url: item.payload.imageUrl,
name: item.payload.title ?? 'image',
fileUsageType: 'IMAGE',
})
break
case 'audio':
await _sendMessage(props, '', {
url: item.payload.audioUrl,
name: item.payload.title ?? 'audio',
fileUsageType: 'AUDIO',
})
break
case 'video':
await _sendMessage(props, '', {
url: item.payload.videoUrl,
name: item.payload.title ?? 'video',
fileUsageType: 'OTHER',
})
break
case 'file':
await _sendMessage(props, '', {
url: item.payload.fileUrl,
name: item.payload.title ?? 'file',
fileUsageType: 'OTHER',
})
break
case 'location':
props.logger.forBot().warn('Location items in bloc messages are not supported in HubSpot HITL — skipping')
break
}
}
},
},
},
}
+368
View File
@@ -0,0 +1,368 @@
import { RuntimeError } from '@botpress/sdk'
import axios from 'axios'
import { getAccessToken } from '../auth'
import { ensureExtension, getMediaMetadata } from './utils/media'
import * as bp from '.botpress'
const HUBSPOT_API_BASE_URL = 'https://api.hubapi.com'
// Retry infrastructure
type RetryConfig = {
maxRetries: number
baseDelay: number
maxDelay: number
backoffMultiplier: number
}
const DEFAULT_RETRY_CONFIG: RetryConfig = {
maxRetries: 5,
baseDelay: 1000,
maxDelay: 32000,
backoffMultiplier: 2,
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function calculateDelay(attempt: number, config: RetryConfig): number {
const exponentialDelay = Math.min(config.baseDelay * Math.pow(config.backoffMultiplier, attempt), config.maxDelay)
const jitter = exponentialDelay * 0.25 * (Math.random() - 0.5)
return Math.floor(exponentialDelay + jitter)
}
function isRateLimitError(error: unknown): boolean {
if (!axios.isAxiosError(error)) return false
return error.response?.status === 429 || error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT'
}
function getRetryAfterMs(error: unknown): number | null {
if (!axios.isAxiosError(error)) return null
const retryAfter = error.response?.headers?.['retry-after']
if (!retryAfter) return null
const parsed = parseInt(retryAfter, 10)
if (!isNaN(parsed)) return parsed * 1000
const date = new Date(retryAfter)
if (!isNaN(date.getTime())) return Math.max(0, date.getTime() - Date.now())
return null
}
type ApiResponse<T> = {
success: boolean
message: string
data: T | null
}
export type ThreadInfo = {
id: string
associatedContactId: string
}
export class HubSpotHitlClient {
private _ctx: bp.Context
private _bpClient: bp.Client
private _logger: bp.Logger
public constructor(ctx: bp.Context, bpClient: bp.Client, logger: bp.Logger) {
this._ctx = ctx
this._bpClient = bpClient
this._logger = logger
}
private async _makeHitlRequest<T>(
endpoint: string,
method: string = 'GET',
data: any = null,
params: any = {},
retryConfig: RetryConfig = DEFAULT_RETRY_CONFIG
): Promise<ApiResponse<T>> {
const accessToken = await getAccessToken({ client: this._bpClient, ctx: this._ctx })
const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
}
if (method !== 'GET' && method !== 'DELETE') {
headers['Content-Type'] = 'application/json'
}
for (let attempt = 0; attempt <= retryConfig.maxRetries; attempt++) {
try {
const response = await axios({ method, url: endpoint, headers, data, params })
return { success: true, message: 'Request successful', data: response.data }
} catch (thrown: unknown) {
const isLast = attempt >= retryConfig.maxRetries
if (!isLast && isRateLimitError(thrown)) {
const delay = getRetryAfterMs(thrown) ?? calculateDelay(attempt, retryConfig)
this._logger
.forBot()
.warn(`Rate limited. Retrying in ${delay / 1000}s (attempt ${attempt + 1}/${retryConfig.maxRetries})`)
await sleep(delay)
continue
}
const errData = axios.isAxiosError(thrown) ? thrown.response?.data : undefined
const message = thrown instanceof Error ? thrown.message : String(thrown)
this._logger.forBot().error('HubSpot API error:', errData || message)
return { success: false, message: errData?.message || message, data: null }
}
}
return { success: false, message: 'Max retries exceeded', data: null }
}
public async getThreadInfo(threadId: string): Promise<ThreadInfo> {
const endpoint = `${HUBSPOT_API_BASE_URL}/conversations/v3/conversations/threads/${threadId}`
const response = await this._makeHitlRequest<ThreadInfo>(endpoint, 'GET')
if (!response.success || !response.data) {
throw new RuntimeError(`Failed to fetch thread info: ${response.message}`)
}
return response.data
}
public async getActorDetails(
actorId: string
): Promise<{ id: string; name: string; email: string; avatar: string; type: string }> {
const endpoint = `${HUBSPOT_API_BASE_URL}/conversations/v3/conversations/actors/${actorId}`
const response = await this._makeHitlRequest<{
id: string
name: string
email: string
avatar: string
type: string
}>(endpoint, 'GET')
if (!response.success || !response.data) {
throw new RuntimeError(`Failed to fetch actor details: ${response.message}`)
}
return response.data
}
public async createCustomChannel(appId: string, developerApiKey?: string): Promise<string> {
const params: Record<string, string> = { appId }
if (developerApiKey) params.hapikey = developerApiKey
const response = await this._makeHitlRequest<{ id: string }>(
`${HUBSPOT_API_BASE_URL}/conversations/v3/custom-channels`,
'POST',
{
name: 'Botpress HITL',
webhookUrl: `${process.env.BP_WEBHOOK_URL}/${this._ctx.webhookId}`,
capabilities: {
deliveryIdentifierTypes: ['CHANNEL_SPECIFIC_OPAQUE_ID'],
richText: ['HYPERLINK', 'TEXT_ALIGNMENT', 'BLOCKQUOTE'],
threadingModel: 'INTEGRATION_THREAD_ID',
allowInlineImages: false,
allowOutgoingMessages: true,
allowConversationStart: true,
maxFileAttachmentCount: 1,
allowMultipleRecipients: false,
outgoingAttachmentTypes: ['FILE'],
maxFileAttachmentSizeBytes: 10_000_000,
maxTotalFileAttachmentSizeBytes: 10_000_000,
allowedFileAttachmentMimeTypes: ['image/*', 'audio/*', 'video/*', 'application/*', 'text/*'],
},
channelAccountConnectionRedirectUrl: 'https://example.com',
channelDescription: 'Botpress custom channel integration.',
channelLogoUrl: 'https://i.imgur.com/CAu3kb7.png',
},
params
)
if (!response.success || !response.data) {
throw new RuntimeError(`createCustomChannel failed: ${response.message}`)
}
return response.data.id
}
public async getCustomChannels(
appId: string,
developerApiKey?: string
): Promise<{ results: Array<{ id: string; webhookUrl: string }> }> {
const params: Record<string, string> = { appId }
if (developerApiKey) params.hapikey = developerApiKey
const response = await this._makeHitlRequest<{ results: Array<{ id: string; webhookUrl: string }> }>(
`${HUBSPOT_API_BASE_URL}/conversations/v3/custom-channels`,
'GET',
null,
params
)
if (!response.success || !response.data) {
throw new RuntimeError(`getCustomChannels failed: ${response.message}`)
}
return response.data
}
public async deleteCustomChannel(
channelId: string,
appId: string,
developerApiKey?: string
): Promise<{ success: boolean }> {
const params: Record<string, string> = { appId }
if (developerApiKey) params.hapikey = developerApiKey
const response = await this._makeHitlRequest(
`${HUBSPOT_API_BASE_URL}/conversations/v3/custom-channels/${channelId}`,
'DELETE',
null,
params
)
if (response.success) {
this._logger.forBot().info(`Deleted custom channel ${channelId}`)
} else {
this._logger.forBot().error(`Failed to delete custom channel ${channelId}: ${response.message}`)
}
return { success: response.success }
}
public async connectCustomChannel(
channelId: string,
inboxOrHelpDeskId: string,
channelName: string
): Promise<ApiResponse<{ id: string }>> {
const endpoint = `${HUBSPOT_API_BASE_URL}/conversations/v3/custom-channels/${channelId}/channel-accounts`
const payload = {
name: channelName,
inboxId: inboxOrHelpDeskId,
deliveryIdentifier: { type: 'CHANNEL_SPECIFIC_OPAQUE_ID', value: `botpress-${inboxOrHelpDeskId}` },
authorized: true,
}
const response = await this._makeHitlRequest<{ id: string }>(endpoint, 'POST', payload)
if (!response.success || !response.data) {
throw new RuntimeError(`connectCustomChannel failed: ${response.message}`)
}
return response
}
public async listChannelAccounts(channelId: string): Promise<Array<{ id: string; inboxId: string }>> {
const endpoint = `${HUBSPOT_API_BASE_URL}/conversations/v3/custom-channels/${channelId}/channel-accounts`
const response = await this._makeHitlRequest<{ results: Array<{ id: string; inboxId: string }> }>(endpoint, 'GET')
if (!response.success || !response.data) {
throw new RuntimeError(`listChannelAccounts failed: ${response.message}`)
}
return response.data.results
}
public async createConversation(
channelId: string,
channelAccountId: string,
integrationThreadId: string,
name: string,
contactIdentifier: string,
title: string,
description: string
): Promise<any> {
const endpoint = `${HUBSPOT_API_BASE_URL}/conversations/v3/custom-channels/${channelId}/messages`
const isEmail = contactIdentifier.includes('@')
const deliveryType = isEmail ? 'HS_EMAIL_ADDRESS' : 'HS_PHONE_NUMBER'
const payload = {
text: `Title: ${title}\nDescription: ${description}`,
messageDirection: 'INCOMING',
integrationThreadId,
channelAccountId,
senders: [
{
name,
deliveryIdentifier: { type: deliveryType, value: contactIdentifier },
},
],
}
const response = await this._makeHitlRequest(endpoint, 'POST', payload)
if (!response.success) {
throw new RuntimeError(`createConversation failed: ${response.message}`)
}
return response
}
public async listInboxes(): Promise<Array<{ id: string; name: string }>> {
const endpoint = `${HUBSPOT_API_BASE_URL}/conversations/v3/conversations/inboxes`
const response = await this._makeHitlRequest<{ results: Array<{ id: string; name: string }> }>(endpoint, 'GET')
if (!response.success || !response.data) {
throw new RuntimeError(`listInboxes failed: ${response.message}`)
}
return response.data.results
}
private async _uploadFile(
fileUrl: string,
name: string,
integrationThreadId: string,
contentType?: string
): Promise<string> {
const accessToken = await getAccessToken({ client: this._bpClient, ctx: this._ctx })
const fileResponse = await axios.get<ArrayBuffer>(fileUrl, { responseType: 'arraybuffer' })
const formData = new FormData()
formData.append('file', new Blob([fileResponse.data], contentType ? { type: contentType } : undefined), name)
formData.append('folderPath', `/botpress-hitl/${integrationThreadId}`)
formData.append('options', JSON.stringify({ access: 'PRIVATE', ttl: 'P1Y' }))
const uploadResponse = await axios.post(`${HUBSPOT_API_BASE_URL}/filemanager/api/v3/files/upload`, formData, {
headers: { Authorization: `Bearer ${accessToken}` },
})
const fileId = uploadResponse.data?.objects?.[0]?.id
if (!fileId) {
throw new RuntimeError('HubSpot file upload returned no fileId')
}
return String(fileId)
}
public async sendMessage(
message: string,
senderName: string,
contactIdentifier: string,
integrationThreadId: string,
channelId: string,
channelAccountId: string,
attachment?: {
url: string
name: string
fileUsageType: 'IMAGE' | 'AUDIO' | 'VOICE_RECORDING' | 'STICKER' | 'OTHER'
}
): Promise<any> {
const endpoint = `${HUBSPOT_API_BASE_URL}/conversations/v3/custom-channels/${channelId}/messages`
const isEmail = contactIdentifier.includes('@')
const deliveryType = isEmail ? 'HS_EMAIL_ADDRESS' : 'HS_PHONE_NUMBER'
const attachments: Array<{ type: string; fileId: string; name: string; fileUsageType: string }> = []
if (attachment) {
const metadata = await getMediaMetadata(attachment.url)
const name = metadata.fileName ?? ensureExtension(attachment.name, attachment.url)
const fileId = await this._uploadFile(attachment.url, name, integrationThreadId, metadata.mimeType)
attachments.push({ type: 'FILE', fileId, name, fileUsageType: attachment.fileUsageType })
}
const payload = {
type: 'MESSAGE',
text: message,
messageDirection: 'INCOMING',
integrationThreadId,
channelAccountId,
senders: [
{
name: senderName,
deliveryIdentifier: { type: deliveryType, value: contactIdentifier },
},
],
attachments,
}
const response = await this._makeHitlRequest(endpoint, 'POST', payload)
if (!response.success) {
throw new RuntimeError(`sendMessage failed: ${response.message}`)
}
return response
}
public async closeThread(threadId: string): Promise<void> {
const endpoint = `${HUBSPOT_API_BASE_URL}/conversations/v3/conversations/threads/${threadId}`
const response = await this._makeHitlRequest(endpoint, 'PATCH', { status: 'CLOSED' })
if (!response.success) {
throw new RuntimeError(`closeThread failed: ${response.message}`)
}
}
}
export const getHitlClient = (ctx: bp.Context, bpClient: bp.Client, logger: bp.Logger): HubSpotHitlClient =>
new HubSpotHitlClient(ctx, bpClient, logger)
@@ -0,0 +1,29 @@
import { getConversationByExternalIdOrThrow } from '../utils/conversation'
import * as bp from '.botpress'
type HubSpotEvent = {
objectId: string | number
subscriptionType: string
propertyName?: string
propertyValue?: string
}
type ConversationCompletedParams = {
hubspotEvent: HubSpotEvent
client: bp.Client
logger: bp.Logger
}
export const handleConversationCompleted = async ({ hubspotEvent, client, logger }: ConversationCompletedParams) => {
try {
const conversation = await getConversationByExternalIdOrThrow(client, hubspotEvent.objectId)
await client.createEvent({
type: 'hitlStopped',
payload: { conversationId: conversation.id },
})
} catch (thrown: unknown) {
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
logger.forBot().error(`Failed to handle "conversation completed" event: ${error.message}`)
}
}
@@ -0,0 +1,63 @@
import { HubSpotHitlClient } from '../client'
import { getConversationByExternalIdOrThrow } from '../utils/conversation'
import * as bp from '.botpress'
type HubSpotEvent = {
objectId: string | number
subscriptionType: string
propertyName?: string
propertyValue?: string
}
type OperatorAssignedParams = {
hubspotEvent: HubSpotEvent
client: bp.Client
hubSpotClient: HubSpotHitlClient
logger: bp.Logger
}
export const handleOperatorAssignedUpdate = async ({
hubspotEvent,
client,
hubSpotClient,
logger,
}: OperatorAssignedParams) => {
try {
const actorId = hubspotEvent.propertyValue
if (!actorId) {
logger.forBot().warn('assignedTo event has no actor — skipping')
return
}
const conversation = await getConversationByExternalIdOrThrow(client, hubspotEvent.objectId)
const { user } = await client.getOrCreateUser({
tags: { actorId },
discriminateByTags: ['actorId'],
})
const details = await hubSpotClient.getActorDetails(actorId).catch(() => null)
if (details) {
await client.updateUser({
id: user.id,
name: details.name,
pictureUrl: details.avatar,
tags: { actorId, email: details.email },
})
}
await client.createEvent({
type: 'hitlAssigned',
payload: {
conversationId: conversation.id,
userId: user.id,
},
})
logger.forBot().info(`hitlAssigned fired: conversation=${conversation.id}, user=${user.id}`)
} catch (thrown: unknown) {
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
logger.forBot().error(`Failed to handle "operator assignment" event: ${error.message}`)
}
}
@@ -0,0 +1,101 @@
import { RuntimeError } from '@botpress/sdk'
import { getConversationByExternalIdOrThrow } from '../utils/conversation'
import * as bp from '.botpress'
type HubSpotAttachment = {
fileId?: string
url?: string
name?: string
type?: string // always 'FILE'
fileUsageType?: 'IMAGE' | 'AUDIO' | 'VOICE_RECORDING' | 'STICKER' | 'OTHER'
}
type HubSpotMessage = {
conversationsThreadId: string
text?: string
senders?: Array<{ actorId: string }>
attachments?: HubSpotAttachment[]
}
type HubSpotEvent = {
type: string
message?: HubSpotMessage
}
type OperatorRepliedParams = {
hubspotEvent: HubSpotEvent
client: bp.Client
logger: bp.Logger
}
export const handleOperatorReplied = async ({ hubspotEvent, client, logger }: OperatorRepliedParams) => {
try {
if (!hubspotEvent.message?.conversationsThreadId) {
throw new RuntimeError('Missing conversation thread ID in operator message')
}
const conversation = await getConversationByExternalIdOrThrow(client, hubspotEvent.message.conversationsThreadId)
const actorId = hubspotEvent.message?.senders?.[0]?.actorId
if (!actorId) {
throw new RuntimeError('Missing actorId in operator message senders')
}
const { user } = await client.getOrCreateUser({
tags: { actorId },
discriminateByTags: ['actorId'],
})
if (!user?.id) {
throw new RuntimeError('Failed to get or create agent user')
}
if (hubspotEvent.message.text) {
await client.createMessage({
tags: {},
type: 'text',
userId: user.id,
conversationId: conversation.id,
payload: { text: hubspotEvent.message.text },
})
}
for (const attachment of hubspotEvent.message.attachments ?? []) {
if (!attachment.url) {
logger.forBot().warn('Skipping attachment with no URL')
continue
}
const usageType = attachment.fileUsageType
if (usageType === 'IMAGE') {
await client.createMessage({
tags: {},
type: 'image',
userId: user.id,
conversationId: conversation.id,
payload: { imageUrl: attachment.url, title: attachment.name },
})
} else if (usageType === 'AUDIO' || usageType === 'VOICE_RECORDING') {
await client.createMessage({
tags: {},
type: 'audio',
userId: user.id,
conversationId: conversation.id,
payload: { audioUrl: attachment.url, title: attachment.name },
})
} else {
await client.createMessage({
tags: {},
type: 'file',
userId: user.id,
conversationId: conversation.id,
payload: { fileUrl: attachment.url, title: attachment.name },
})
}
}
} catch (thrown: unknown) {
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
logger.forBot().error(`Failed to handle "operator replied" event: ${error.message}`)
}
}
+100
View File
@@ -0,0 +1,100 @@
import { getHitlClient, HubSpotHitlClient } from './client'
import * as bp from '.botpress'
export async function createHitlChannel(props: {
ctx: bp.Context
client: bp.Client
logger: bp.Logger
appId: string
developerApiKey: string | undefined
}): Promise<string> {
const { ctx, client, logger, appId, developerApiKey } = props
const hitlClient = getHitlClient(ctx, client, logger)
const ourWebhookUrl = `${process.env.BP_WEBHOOK_URL}/${ctx.webhookId}`
const { results } = await hitlClient.getCustomChannels(appId, developerApiKey)
const existing = results.find((c) => c.webhookUrl === ourWebhookUrl)
if (existing) {
logger.forBot().info(`Found existing HITL channel ${existing.id} with matching webhookUrl. Reusing it.`)
return existing.id
}
const newChannelId = await hitlClient.createCustomChannel(appId, developerApiKey)
logger.forBot().info(`Created HITL custom channel: ${newChannelId}`)
return newChannelId
}
export async function connectHitlChannel(props: {
ctx: bp.Context
client: bp.Client
logger: bp.Logger
channelId: string
inboxIds: string[]
defaultInboxId: string
}): Promise<void> {
const { ctx, client, logger, channelId, inboxIds, defaultInboxId } = props
const hitlClient = getHitlClient(ctx, client, logger)
const existingAccounts = await hitlClient.listChannelAccounts(channelId)
const channelAccounts: Record<string, string> = {}
for (const inboxId of inboxIds) {
const existing = existingAccounts.find((a) => a.inboxId === inboxId)
if (existing) {
logger.forBot().info(`Reusing existing channel account ${existing.id} for inbox ${inboxId}`)
channelAccounts[inboxId] = existing.id
} else {
const channelAccount = await hitlClient.connectCustomChannel(channelId, inboxId, 'Botpress Channel')
channelAccounts[inboxId] = channelAccount.data!.id
logger.forBot().info(`Created channel account ${channelAccounts[inboxId]} for inbox ${inboxId}`)
}
}
await client.setState({
type: 'integration',
name: 'hitlConfig',
id: ctx.integrationId,
payload: { channelId, defaultInboxId, channelAccounts },
})
logger
.forBot()
.info(`HITL channel ${channelId} connected to ${inboxIds.length} inbox(es). Default: ${defaultInboxId}`)
}
export async function setupHitlChannel(props: {
ctx: bp.Context
client: bp.Client
logger: bp.Logger
appId: string
developerApiKey: string | undefined
inboxIds: string[]
}): Promise<void> {
const { ctx, client, logger, appId, developerApiKey, inboxIds } = props
const hitlClient = getHitlClient(ctx, client, logger)
const channelId = await createHitlChannel({ ctx, client, logger, appId, developerApiKey })
await _waitForChannelAvailability(hitlClient, channelId, appId, developerApiKey, logger)
await connectHitlChannel({ ctx, client, logger, channelId, inboxIds, defaultInboxId: inboxIds[0]! })
}
async function _waitForChannelAvailability(
hitlClient: HubSpotHitlClient,
channelId: string,
appId: string,
developerApiKey: string | undefined,
logger: bp.Logger
): Promise<void> {
const maxAttempts = 6
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const { results } = await hitlClient.getCustomChannels(appId, developerApiKey)
if (results.some((c) => c.id === channelId)) {
logger.forBot().info(`Channel ${channelId} available after ${attempt + 1} attempt(s)`)
return
}
const delay = Math.pow(2, attempt) * 1000
logger.forBot().warn(`Channel ${channelId} not yet available. Retrying in ${delay / 1000}s...`)
await new Promise((resolve) => setTimeout(resolve, delay))
}
logger.forBot().warn(`Channel ${channelId} still not visible after ${maxAttempts} attempts — proceeding anyway`)
}
@@ -0,0 +1,15 @@
import { RuntimeError } from '@botpress/sdk'
import * as bp from '.botpress'
export const getConversationByExternalIdOrThrow = async (client: bp.Client, externalId: string | number) => {
const { conversations } = await client.listConversations({
tags: { id: String(externalId) },
})
const conversation = conversations[0]
if (!conversation) {
throw new RuntimeError(`No HITL conversation found for external ID ${externalId}`)
}
return conversation
}
@@ -0,0 +1,32 @@
const _EXT_ALIASES: Record<string, string> = { mpga: 'mp3', jfif: 'jpg', jpeg: 'jpg' }
export function ensureExtension(name: string, fileUrl: string): string {
if (name.includes('.')) {
return name
}
const pathname = new URL(fileUrl).pathname
const ext = pathname.split('.').pop()
const normalizedExt = ext ? (_EXT_ALIASES[ext] ?? ext) : undefined
return normalizedExt ? `${name}.${normalizedExt}` : name
}
export type FileMetadata = { mimeType: string; fileSize?: number; fileName?: string }
export async function getMediaMetadata(url: string): Promise<FileMetadata> {
const response = await fetch(url, { method: 'HEAD' })
if (!response.ok) {
throw new Error(`Failed to fetch file metadata for URL: ${url}`)
}
const mimeType = response.headers.get('content-type') ?? 'application/octet-stream'
const contentLength = response.headers.get('content-length')
const contentDisposition = response.headers.get('content-disposition')
const fileSize = contentLength ? Number(contentLength) : undefined
let fileName: string | undefined
if (contentDisposition) {
const match = contentDisposition.match(/filename\*?=(?:UTF-8'')?["']?([^"';\r\n]+)["']?/i)
if (match?.[1]) {
fileName = decodeURIComponent(match[1].trim())
}
}
return { mimeType, fileSize, fileName }
}
@@ -0,0 +1,44 @@
import * as crypto from 'crypto'
import * as bp from '.botpress'
/**
* Validates the HubSpot webhook signature v3 (HMAC-SHA256).
* Used for Custom Channel webhook events.
*/
export function validateHubSpotSignature(
requestBody: string,
signature: string,
timestamp: string,
method: string,
webhookUrl: string,
clientSecret: string,
logger: bp.Logger
): boolean {
if (!signature || !clientSecret || !timestamp) {
logger.forBot().error('Missing required headers or client secret for HubSpot signature validation')
return false
}
const MAX_ALLOWED_TIMESTAMP_MS = 300000 // 5 minutes
const timestampDiff = Date.now() - parseInt(timestamp)
if (timestampDiff > MAX_ALLOWED_TIMESTAMP_MS) {
logger.forBot().error(`HubSpot webhook timestamp too old: ${timestampDiff}ms`)
return false
}
const rawString = `${method}${webhookUrl}${requestBody}${timestamp}`
const hmac = crypto.createHmac('sha256', clientSecret)
hmac.update(rawString)
const computedSignature = hmac.digest('base64')
try {
const isValid = crypto.timingSafeEqual(Buffer.from(computedSignature), Buffer.from(signature))
if (!isValid) {
logger.forBot().error('Invalid HubSpot webhook signature v3')
}
return isValid
} catch {
logger.forBot().error('HubSpot signature comparison failed (length mismatch)')
return false
}
}
@@ -0,0 +1,36 @@
import { createAsyncFnWrapperWithErrorRedaction, createErrorHandlingDecorator } from '@botpress/common'
import * as sdk from '@botpress/sdk'
type HubspotApiError = Error & {
code: number
body?: {
policyName?: string
message?: string
}
}
const _errorRedactor = (error: Error, customMessage: string) => {
if (error instanceof sdk.RuntimeError) {
return error
}
console.warn(customMessage, error)
if (_isHubspotApiError(error)) {
return new sdk.RuntimeError(
`${customMessage}. HubSpot API responded with status code ${error.code}${
error.body?.message ? ` and message: ${error.body.message}` : ''
}${error.body?.policyName ? ` (policy: ${error.body.policyName})` : ''}`
)
}
return new sdk.RuntimeError(customMessage)
}
export const wrapAsyncFnWithTryCatch = createAsyncFnWrapperWithErrorRedaction(_errorRedactor)
const _isHubspotApiError = (error: unknown): error is HubspotApiError => {
return error instanceof Error && 'code' in error && typeof (error as any).code === 'number'
}
export const handleErrorsDecorator = createErrorHandlingDecorator(wrapAsyncFnWithTryCatch)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
export { HubspotClient } from './hubspot-client'
@@ -0,0 +1,143 @@
import { RuntimeError } from '@botpress/sdk'
import { Client as OfficialHubspotClient } from '@hubspot/api-client'
import { CrmObjectType, propertyTypeSchema } from '../../definitions/states'
import * as bp from '.botpress'
type Properties = NonNullable<bp.states.States[`${CrmObjectType}PropertyCache`]['payload']['properties']>
type Property = Properties[string]
export class PropertiesCache {
private readonly _client: bp.Client
private readonly _ctx: bp.Context
private readonly _hsClient: OfficialHubspotClient
private readonly _type: CrmObjectType
private _properties?: Properties
private _forceRefresh: boolean = false
private _alreadyRefreshed: boolean = false
public constructor({
client,
ctx,
accessToken,
type,
}: {
client: bp.Client
ctx: bp.Context
accessToken: string
type: CrmObjectType
}) {
this._client = client
this._ctx = ctx
this._type = type
this._hsClient = new OfficialHubspotClient({ accessToken, numberOfApiCallRetries: 2 })
}
public static create({
client,
ctx,
accessToken,
type,
}: {
client: bp.Client
ctx: bp.Context
accessToken: string
type: CrmObjectType
}) {
return new PropertiesCache({ client, ctx, accessToken, type })
}
public async getProperty({ nameOrLabel }: { nameOrLabel: string }) {
const knownProperties = await this._getPropertiesOrRefresh()
let property = this._resolveProperty(nameOrLabel, knownProperties)
if (!property || this._forceRefresh) {
// Refresh, then do a second pass
await this._refreshPropertiesFromApi()
property = this._resolveProperty(nameOrLabel, await this._getPropertiesOrRefresh())
if (!property) {
// At this point, we give up
throw new RuntimeError(`Unable to find ${this._type} property with name or label "${nameOrLabel}"`)
}
}
return property
}
public invalidate() {
this._forceRefresh = true
}
private _resolveProperty(nameOrLabel: string, properties: Properties) {
const normalizedProperties = _propertiesRecordToNormalizedArray(properties)
return normalizedProperties.find((property) => {
if (property.name === nameOrLabel || property.label === nameOrLabel) {
return property
}
const canonicalName = _getCanonicalName(nameOrLabel)
return property.name === canonicalName || property.label === canonicalName
})
}
private async _getPropertiesOrRefresh() {
if (!this._properties) {
try {
const { state } = await this._client.getState({
type: 'integration',
id: this._ctx.integrationId,
name: `${this._type}PropertyCache`,
})
this._properties = state.payload.properties
} catch {
await this._refreshPropertiesFromApi()
}
}
if (!this._properties) {
throw new RuntimeError('Could not update properties cache')
}
return this._properties
}
private async _refreshPropertiesFromApi() {
if (this._alreadyRefreshed && !this._forceRefresh) {
// Prevent refreshing several times in a single lambda invocation
return
}
const properties = await this._hsClient.crm.properties.coreApi.getAll(this._type, false)
this._properties = Object.fromEntries(
properties.results.map((prop) => {
const parseResult = propertyTypeSchema.safeParse(prop.type)
if (!parseResult.success) {
throw new RuntimeError(`Invalid property type "${prop.type}" for ${this._type} property "${prop.label}"`)
}
const propFields: Property = {
label: prop.label,
type: parseResult.data,
hubspotDefined: prop.hubspotDefined ?? false,
}
if (prop.options) {
propFields['options'] = prop.options.map((option) => option.value)
}
return [prop.name, propFields] as const
})
)
await this._client.setState({
type: 'integration',
id: this._ctx.integrationId,
name: `${this._type}PropertyCache`,
payload: { properties: this._properties },
})
this._alreadyRefreshed = true
this._forceRefresh = false
}
}
const _propertiesRecordToNormalizedArray = (properties: Properties) => {
return Object.entries(properties).map(([name, property]) => ({ name, ...property }))
}
const _getCanonicalName = (nameOrLabel: string) => nameOrLabel.trim().toUpperCase()
+13
View File
@@ -0,0 +1,13 @@
import actions from './actions'
import { channels } from './hitl/channel-handler'
import { register, unregister } from './setup'
import { handler } from './webhook'
import * as bp from '.botpress'
export default new bp.Integration({
register,
unregister,
actions,
channels,
handler,
})
+75
View File
@@ -0,0 +1,75 @@
import { RuntimeError } from '@botpress/sdk'
import { getHitlClient } from './hitl/client'
import { setupHitlChannel } from './hitl/setup'
import * as bp from '.botpress'
export const register: bp.IntegrationProps['register'] = async ({ client, ctx, logger }) => {
if (ctx.configurationType === null && bp.secrets.DISABLE_OAUTH === 'true') {
await client.configureIntegration({
identifier: null,
})
throw new RuntimeError('OAuth currently unavailable, please use manual configuration instead')
}
if (ctx.configurationType !== 'manual') {
return
}
if (!ctx.configuration.inboxIds?.length) {
return
}
const { appId, developerApiKey } = _resolveHitlCredentials(ctx)
if (!appId) {
throw new RuntimeError('APP_ID is required for HITL in manual mode. Please configure it.')
}
await setupHitlChannel({
ctx,
client,
logger,
appId,
developerApiKey,
inboxIds: ctx.configuration.inboxIds,
})
}
export const unregister: bp.IntegrationProps['unregister'] = async ({ client, ctx, logger }) => {
const channelState = await client
.getState({ type: 'integration', name: 'hitlConfig', id: ctx.integrationId })
.catch(() => null)
if (!channelState?.state?.payload?.channelId) {
return
}
const { channelId } = channelState.state.payload
const hitlClient = getHitlClient(ctx, client, logger)
const { appId, developerApiKey } = _resolveHitlCredentials(ctx)
if (!appId) {
logger.forBot().warn('Cannot archive HITL channel: APP_ID not configured')
return
}
const result = await hitlClient.deleteCustomChannel(channelId, appId, developerApiKey)
if (result.success) {
logger.forBot().info(`Archived HITL custom channel ${channelId}`)
} else {
logger.forBot().warn(`Could not archive HITL channel ${channelId} — may need manual cleanup`)
}
}
function _resolveHitlCredentials(ctx: bp.Context): { appId: string | undefined; developerApiKey: string | undefined } {
if (ctx.configurationType === 'manual') {
return {
appId: ctx.configuration.appId,
developerApiKey: ctx.configuration.developerApiKey,
}
}
return {
appId: bp.secrets.APP_ID,
developerApiKey: bp.secrets.DEVELOPER_API_KEY,
}
}
+84
View File
@@ -0,0 +1,84 @@
import { isApiError } from '@botpress/sdk'
import { getAccessToken } from './auth'
import { HubspotClient } from './hubspot-api'
import * as bp from '.botpress'
export const useDeskOAuth = ({ env, source }: bp.states.environment.Environment['payload']) =>
env === 'production' && source === 'desk'
export const getEnvironment = async ({
client,
ctx,
}: {
client: bp.Client
ctx: bp.Context
}): Promise<bp.states.environment.Environment['payload']> => {
const {
state: { payload },
} = await client
.getState({ type: 'integration', name: 'environment', id: ctx.integrationId })
.catch(() => ({ state: { payload: { env: 'preview' as const, source: undefined } } }))
return payload
}
export const getAuthenticatedHubspotClient = async ({
ctx,
client,
logger,
}: {
ctx: bp.Context
client: bp.Client
logger: bp.Logger
}) => {
return new HubspotClient({ accessToken: await getAccessToken({ client, ctx }), client, ctx, logger })
}
export const propertiesEntriesToRecord = (properties: { name: string; value: string }[]) => {
return Object.fromEntries(properties.map(({ name, value }) => [name, value]))
}
export const setPortalId = async ({
client,
ctx,
portalId,
}: {
client: bp.Client
ctx: bp.Context
portalId: string
}) => {
await client.setState({
type: 'integration',
name: 'hubInfo',
id: ctx.integrationId,
payload: { portalId },
})
}
export const buildContactUrl = ({ portalId, contactId }: { portalId: string; contactId: string }) =>
`https://app.hubspot.com/contacts/${portalId}/contact/${contactId}`
export const getOrFetchPortalId = async ({
client,
ctx,
hsClient,
}: {
client: bp.Client
ctx: bp.Context
hsClient: HubspotClient
}): Promise<string> => {
const cached = await client
.getState({ type: 'integration', name: 'hubInfo', id: ctx.integrationId })
.then((s) => s.state.payload.portalId)
.catch((e: unknown) => {
if (isApiError(e) && e.code === 404) {
return undefined
}
throw e
})
if (cached) {
return cached
}
const portalId = await hsClient.getHubId()
await setPortalId({ client, ctx, portalId })
return portalId
}
@@ -0,0 +1,115 @@
import * as sdk from '@botpress/sdk'
const OBJECT_TYPES = {
CONTACT: '0-1',
COMPANY: '0-2',
TICKET: '0-5',
LEAD: '0-136',
} as const
const BASE_EVENT_PAYLOAD = sdk.z.object({
eventId: sdk.z.number(),
changeSource: sdk.z.string(),
occurredAt: sdk.z.number().nonnegative(),
subscriptionType: sdk.z.enum(['object.creation', 'object.deletion']),
attemptNumber: sdk.z.number().nonnegative(),
objectId: sdk.z.number(),
objectTypeId: sdk.z.enum([OBJECT_TYPES.COMPANY, OBJECT_TYPES.CONTACT, OBJECT_TYPES.LEAD, OBJECT_TYPES.TICKET]),
})
type BaseEvent = sdk.z.infer<typeof BASE_EVENT_PAYLOAD>
const CONTACT_CREATED_EVENT = BASE_EVENT_PAYLOAD.extend({
subscriptionType: sdk.z.literal('object.creation'),
objectTypeId: sdk.z.literal(OBJECT_TYPES.CONTACT),
})
export type ContactCreatedEvent = sdk.z.infer<typeof CONTACT_CREATED_EVENT>
export const isContactCreatedEvent = (event: BaseEvent): event is ContactCreatedEvent =>
event.subscriptionType === 'object.creation' && event.objectTypeId === OBJECT_TYPES.CONTACT
const CONTACT_DELETED_EVENT = BASE_EVENT_PAYLOAD.extend({
subscriptionType: sdk.z.literal('object.deletion'),
objectTypeId: sdk.z.literal(OBJECT_TYPES.CONTACT),
})
export type ContactDeletedEvent = sdk.z.infer<typeof CONTACT_DELETED_EVENT>
export const isContactDeletedEvent = (event: BaseEvent): event is ContactDeletedEvent =>
event.subscriptionType === 'object.deletion' && event.objectTypeId === OBJECT_TYPES.CONTACT
const COMPANY_CREATED_EVENT = BASE_EVENT_PAYLOAD.extend({
subscriptionType: sdk.z.literal('object.creation'),
objectTypeId: sdk.z.literal(OBJECT_TYPES.COMPANY),
})
export type CompanyCreatedEvent = sdk.z.infer<typeof COMPANY_CREATED_EVENT>
export const isCompanyCreatedEvent = (event: BaseEvent): event is CompanyCreatedEvent =>
event.subscriptionType === 'object.creation' && event.objectTypeId === OBJECT_TYPES.COMPANY
const COMPANY_DELETED_EVENT = BASE_EVENT_PAYLOAD.extend({
subscriptionType: sdk.z.literal('object.deletion'),
objectTypeId: sdk.z.literal(OBJECT_TYPES.COMPANY),
})
export type CompanyDeletedEvent = sdk.z.infer<typeof COMPANY_DELETED_EVENT>
export const isCompanyDeletedEvent = (event: BaseEvent): event is CompanyDeletedEvent =>
event.subscriptionType === 'object.deletion' && event.objectTypeId === OBJECT_TYPES.COMPANY
const TICKET_CREATED_EVENT = BASE_EVENT_PAYLOAD.extend({
subscriptionType: sdk.z.literal('object.creation'),
objectTypeId: sdk.z.literal(OBJECT_TYPES.TICKET),
})
export type TicketCreatedEvent = sdk.z.infer<typeof TICKET_CREATED_EVENT>
export const isTicketCreatedEvent = (event: BaseEvent): event is TicketCreatedEvent =>
event.subscriptionType === 'object.creation' && event.objectTypeId === OBJECT_TYPES.TICKET
const TICKET_DELETED_EVENT = BASE_EVENT_PAYLOAD.extend({
subscriptionType: sdk.z.literal('object.deletion'),
objectTypeId: sdk.z.literal(OBJECT_TYPES.TICKET),
})
export type TicketDeletedEvent = sdk.z.infer<typeof TICKET_DELETED_EVENT>
export const isTicketDeletedEvent = (event: BaseEvent): event is TicketDeletedEvent =>
event.subscriptionType === 'object.deletion' && event.objectTypeId === OBJECT_TYPES.TICKET
const LEAD_CREATED_EVENT = BASE_EVENT_PAYLOAD.extend({
subscriptionType: sdk.z.literal('object.creation'),
objectTypeId: sdk.z.literal(OBJECT_TYPES.LEAD),
})
export type LeadCreatedEvent = sdk.z.infer<typeof LEAD_CREATED_EVENT>
export const isLeadCreatedEvent = (event: BaseEvent): event is LeadCreatedEvent =>
event.subscriptionType === 'object.creation' && event.objectTypeId === OBJECT_TYPES.LEAD
const LEAD_DELETED_EVENT = BASE_EVENT_PAYLOAD.extend({
subscriptionType: sdk.z.literal('object.deletion'),
objectTypeId: sdk.z.literal(OBJECT_TYPES.LEAD),
})
export type LeadDeletedEvent = sdk.z.infer<typeof LEAD_DELETED_EVENT>
export const isLeadDeletedEvent = (event: BaseEvent): event is LeadDeletedEvent =>
event.subscriptionType === 'object.deletion' && event.objectTypeId === OBJECT_TYPES.LEAD
export const BATCH_UPDATE_EVENT_PAYLOAD = sdk.z.array(
sdk.z.union([
CONTACT_CREATED_EVENT,
CONTACT_DELETED_EVENT,
COMPANY_CREATED_EVENT,
COMPANY_DELETED_EVENT,
TICKET_CREATED_EVENT,
TICKET_DELETED_EVENT,
LEAD_CREATED_EVENT,
LEAD_DELETED_EVENT,
])
)
export type BatchUpdateEventPayload = sdk.z.infer<typeof BATCH_UPDATE_EVENT_PAYLOAD>
+142
View File
@@ -0,0 +1,142 @@
import * as oauthWizard from '@botpress/common/src/oauth-wizard'
import { OAUTH_IDENTIFIER_HEADER } from '@botpress/sdk'
import { Signature } from '@hubspot/api-client'
import { getClientSecret } from '../auth'
import { handleOperatorReplied } from '../hitl/events/operator-replied'
import { validateHubSpotSignature } from '../hitl/utils/signature'
import * as handlers from './handlers'
import { buildOAuthWizard } from './handlers/oauth-wizard'
import * as bp from '.botpress'
export const handler: bp.IntegrationProps['handler'] = async (props) => {
const { req, logger } = props
logger.debug(`Received request on ${req.path}: ${JSON.stringify(req.body, null, 2)}`)
if (oauthWizard.isOAuthWizardUrl(req.path)) {
try {
return await buildOAuthWizard(props).handleRequest()
} catch (thrown: unknown) {
const errMsg = thrown instanceof Error ? thrown.message : String(thrown)
return oauthWizard.generateRedirection(oauthWizard.getInterstitialUrl(false, errMsg))
}
}
if (req.path.startsWith('/oauth')) {
const modifiedProps = { ...props, req: { ...props.req, path: '/oauth/wizard/oauth-callback' } }
try {
const wizardResult = await buildOAuthWizard(modifiedProps).handleRequest()
const identifier = wizardResult.headers?.[OAUTH_IDENTIFIER_HEADER]
return identifier
? {
status: 200,
headers: { [OAUTH_IDENTIFIER_HEADER]: identifier },
}
: wizardResult
} catch (thrown: unknown) {
const errMsg = thrown instanceof Error ? thrown.message : String(thrown)
return oauthWizard.generateRedirection(oauthWizard.getInterstitialUrl(false, errMsg))
}
}
if (_isCustomChannelEvent(req.body)) {
return await _handleHitlEvent(props)
}
// Global webhook subscriptions (conversation updates + CRM events)
if (handlers.isConversationEvent(props) || handlers.isBatchUpdateEvent(props)) {
const validation = await _validateRequestAuthentication(props)
if (validation.error) {
logger.error(`Error validating request: ${validation.message}`)
return { status: 401, body: validation.message }
}
if (handlers.isConversationEvent(props)) {
return await handlers.handleConversationEvent(props)
}
return await handlers.handleBatchUpdateEvent(props)
}
logger.warn('No handler found for request')
}
const _isCustomChannelEvent = (body: string | undefined): boolean => {
if (!body?.length) return false
try {
const parsed = typeof body === 'string' ? JSON.parse(body) : body
return typeof parsed === 'object' && !Array.isArray(parsed) && typeof parsed.type === 'string'
} catch {
return false
}
}
const _handleHitlEvent: bp.IntegrationProps['handler'] = async ({ req, ctx, client, logger }) => {
const signature = req.headers['x-hubspot-signature-v3'] as string
const timestamp = req.headers['x-hubspot-request-timestamp'] as string
const rawBody = typeof req.body === 'string' ? req.body : JSON.stringify(req.body)
const webhookUrl = `${process.env.BP_WEBHOOK_URL}/${ctx.webhookId}`
const clientSecret = await getClientSecret({ client, ctx })
if (clientSecret) {
const isValid = validateHubSpotSignature(
rawBody,
signature,
timestamp,
req.method,
webhookUrl,
clientSecret,
logger
)
if (!isValid) {
logger.forBot().error('Invalid HubSpot v3 signature — rejecting HITL event')
return { status: 401, body: 'Invalid signature' }
}
logger.forBot().info('HubSpot v3 webhook signature verified')
} else {
logger.forBot().warn('No client secret configured — skipping HITL webhook signature validation')
}
let payload: any
try {
payload = typeof req.body === 'string' ? JSON.parse(req.body) : req.body
} catch (err) {
logger.forBot().error('Failed to parse HITL request body:', err)
return { status: 400, body: 'Invalid JSON body' }
}
if (payload.type === 'OUTGOING_CHANNEL_MESSAGE_CREATED') {
return await handleOperatorReplied({ hubspotEvent: payload, client, logger })
}
if (payload.type === 'CHANNEL_ACCOUNT_CREATED') {
logger.forBot().info(`Channel account created: ${JSON.stringify(payload)}`)
return {}
}
logger.forBot().warn(`Unhandled HubSpot HITL event format: ${payload.type}`)
return {}
}
const _validateRequestAuthentication = async ({ req, ctx, client }: bp.HandlerProps) => {
const clientSecret = await getClientSecret({ client, ctx })
if (!clientSecret) {
return { error: false }
}
const signature = req.headers['x-hubspot-signature']
if (!signature) {
return { error: true, message: 'Missing "x-hubspot-signature" header' }
}
const isValid = Signature.isValid({
clientSecret,
requestBody: req.body ?? '',
signature,
})
if (!isValid) {
return { error: true, message: 'Invalid signature' }
}
return { error: false }
}
@@ -0,0 +1,100 @@
import * as sdk from '@botpress/sdk'
import { getAccessToken } from '../../auth'
import { HubspotClient } from '../../hubspot-api'
import {
BATCH_UPDATE_EVENT_PAYLOAD,
isCompanyCreatedEvent,
isCompanyDeletedEvent,
isContactCreatedEvent,
isContactDeletedEvent,
isLeadCreatedEvent,
isLeadDeletedEvent,
isTicketCreatedEvent,
isTicketDeletedEvent,
} from '../event-payloads'
import * as bp from '.botpress'
export const isBatchUpdateEvent = (props: bp.HandlerProps): boolean =>
Boolean(
props.req.method.toUpperCase() === 'POST' &&
props.req.body?.length &&
BATCH_UPDATE_EVENT_PAYLOAD.safeParse(JSON.parse(props.req.body)).success
)
export const handleBatchUpdateEvent: bp.IntegrationProps['handler'] = async (props) => {
const hsClient = new HubspotClient({ accessToken: await getAccessToken(props), ...props })
const events: sdk.z.infer<typeof BATCH_UPDATE_EVENT_PAYLOAD> = JSON.parse(props.req.body!)
for (const event of events) {
switch (true) {
case isContactCreatedEvent(event):
const contact = await hsClient.getContactById({ contactId: event.objectId })
await props.client.createEvent({
type: 'contactCreated',
payload: {
contactId: event.objectId.toString(),
email: contact?.properties.email ?? undefined,
phoneNumber: contact?.properties.phone ?? undefined,
name: `${contact?.properties.firstname} ${contact?.properties.lastname}`.trim() || undefined,
},
})
break
case isContactDeletedEvent(event):
await props.client.createEvent({
type: 'contactDeleted',
payload: {
contactId: event.objectId.toString(),
},
})
break
case isCompanyCreatedEvent(event):
const company = await hsClient.getCompanyById({ companyId: event.objectId })
await props.client.createEvent({
type: 'companyCreated',
payload: {
companyId: event.objectId.toString(),
domain: company?.properties.domain ?? undefined,
phoneNumber: company?.properties.phone ?? undefined,
name: company?.properties.name ?? undefined,
},
})
break
case isCompanyDeletedEvent(event):
await props.client.createEvent({
type: 'companyDeleted',
payload: {
companyId: event.objectId.toString(),
},
})
break
case isTicketCreatedEvent(event):
const ticket = await hsClient.getTicketById({ ticketId: event.objectId })
await props.client.createEvent({
type: 'ticketCreated',
payload: {
ticketId: event.objectId.toString(),
subject: ticket?.properties.subject ?? undefined,
priority: ticket?.properties.hs_ticket_priority ?? undefined,
category: ticket?.properties.hs_ticket_category ?? undefined,
pipeline: ticket?.pipeline.label,
stage: ticket?.pipelineStage.label,
},
})
break
case isTicketDeletedEvent(event):
await props.client.createEvent({
type: 'ticketDeleted',
payload: {
ticketId: event.objectId.toString(),
},
})
break
case isLeadCreatedEvent(event):
break
case isLeadDeletedEvent(event):
break
default:
event satisfies never
}
}
}
@@ -0,0 +1,54 @@
import { getHitlClient } from '../../hitl/client'
import { handleConversationCompleted } from '../../hitl/events/conversation-completed'
import { handleOperatorAssignedUpdate } from '../../hitl/events/operator-assigned'
import * as bp from '.botpress'
type ConversationEvent = {
subscriptionType: string
objectId: string | number
propertyName?: string
propertyValue?: string
}
export const isConversationEvent = (props: bp.HandlerProps): boolean => {
if (props.req.method.toUpperCase() !== 'POST' || !props.req.body?.length) {
return false
}
try {
const parsed = JSON.parse(props.req.body)
if (!Array.isArray(parsed) || parsed.length === 0) return false
const type: string = parsed[0].subscriptionType ?? ''
return type.startsWith('conversation.') || type.startsWith('conversations.')
} catch {
return false
}
}
export const handleConversationEvent: bp.IntegrationProps['handler'] = async ({ req, ctx, client, logger }) => {
let events: ConversationEvent[]
try {
events = JSON.parse(req.body!)
} catch {
return { status: 400, body: 'Invalid JSON body' }
}
const hubSpotClient = getHitlClient(ctx, client, logger)
for (const event of events) {
if (event.subscriptionType === 'conversation.propertyChange') {
if (event.propertyName === 'assignedTo' && event.propertyValue) {
logger.forBot().info(`Operator assigned: ${event.propertyValue}`)
await handleOperatorAssignedUpdate({ hubspotEvent: event, client, hubSpotClient, logger })
}
if (event.propertyName === 'status' && (event.propertyValue === 'CLOSED' || event.propertyValue === 'ARCHIVED')) {
logger.forBot().info(`Conversation ${event.propertyValue} by operator`)
await handleConversationCompleted({ hubspotEvent: event, client, logger })
}
} else {
logger.forBot().info(`Event ${event.subscriptionType} not handled`)
}
}
return {}
}
@@ -0,0 +1,2 @@
export * from './batch-update'
export * from './conversation-events'
@@ -0,0 +1,381 @@
import { generateRawHtmlDialog } from '@botpress/common/src/html-dialogs'
import * as oauthWizard from '@botpress/common/src/oauth-wizard'
import { z } from '@botpress/sdk'
import { exchangeCodeForOAuthCredentials, setOAuthCredentials } from '../../auth'
import { getHitlClient } from '../../hitl/client'
import { createHitlChannel, connectHitlChannel } from '../../hitl/setup'
import { HubspotClient } from '../../hubspot-api'
import { getEnvironment, setPortalId, useDeskOAuth } from '../../utils'
import * as bp from '.botpress'
const REDIRECT_URI = `${process.env.BP_WEBHOOK_URL}/oauth`
const CRM_SCOPES = [
'oauth',
'crm.objects.contacts.read',
'crm.objects.contacts.write',
'tickets',
'crm.objects.owners.read',
'crm.objects.companies.read',
'crm.objects.companies.write',
'crm.objects.leads.read',
'crm.objects.leads.write',
'crm.objects.deals.read',
'crm.objects.deals.write',
'files',
'files.ui_hidden.read',
]
const DESK_SCOPES = [
'crm.objects.companies.read',
'crm.objects.contacts.read',
'crm.objects.owners.read',
'files',
'files.ui_hidden.read',
'oauth',
]
const HITL_SCOPES = [
'conversations.custom_channels.read',
'conversations.custom_channels.write',
'conversations.read',
'conversations.write',
'files',
]
const _startStep: oauthWizard.WizardStepHandler<bp.HandlerProps> = async ({
client,
ctx,
query,
selectedChoice,
responses,
}) => {
if (selectedChoice) {
await client.setState({
type: 'integration',
name: 'hitlSetupWizard',
id: ctx.integrationId,
payload: { enableHitl: selectedChoice === 'with-hitl' },
})
return responses.redirectToStep('oauth-redirect')
}
const environmentPayload = {
source: query.get('source') ?? undefined,
env: z.enum(['preview', 'production']).catch('preview').parse(query.get('env')),
} as bp.states.environment.Environment['payload']
await client.setState({
type: 'integration',
name: 'environment',
id: ctx.integrationId,
payload: environmentPayload,
})
if (environmentPayload.source === 'desk') {
await client.setState({
type: 'integration',
name: 'hitlSetupWizard',
id: ctx.integrationId,
payload: { enableHitl: false },
})
return responses.redirectToStep('oauth-redirect')
}
const previouslyEnabledHitl = await client
.getState({ type: 'integration', name: 'hitlSetupWizard', id: ctx.integrationId })
.then((s) => s.state.payload.enableHitl ?? false)
.catch(() => false)
return responses.displayChoices({
pageTitle: 'Connect HubSpot',
htmlOrMarkdownPageContents:
'Choose how you want to connect HubSpot.\n\n' +
'HITL (Human-in-the-Loop) lets your agents handle conversations directly from HubSpot. ' +
'It requires a Help Desk or Conversations Inbox in your HubSpot account.',
choices: [
{ label: 'Connect to HubSpot (CRM only)', value: 'without-hitl' },
{ label: 'Connect to HubSpot with HITL (Human-in-the-Loop)', value: 'with-hitl' },
],
nextStepId: 'start',
defaultValues: [previouslyEnabledHitl ? 'with-hitl' : 'without-hitl'],
})
}
const _oauthRedirectStep: oauthWizard.WizardStepHandler<bp.HandlerProps> = async ({ ctx, client, responses }) => {
const hitlSetupWizardState = await client
.getState({ type: 'integration', name: 'hitlSetupWizard', id: ctx.integrationId })
.catch(() => null)
const environment = await getEnvironment({ client, ctx })
const enableHitl = hitlSetupWizardState?.state?.payload?.enableHitl ?? false
const scopes =
environment.source === 'desk' && environment.env === 'production'
? DESK_SCOPES
: enableHitl
? [...CRM_SCOPES, ...HITL_SCOPES]
: CRM_SCOPES
const scopesStr = encodeURIComponent(scopes.join(' '))
const clientId = useDeskOAuth(environment) ? bp.secrets.DESK_CLIENT_ID : bp.secrets.CLIENT_ID
const url =
'https://app.hubspot.com/oauth/authorize' +
`?client_id=${clientId}` +
`&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` +
`&state=${ctx.webhookId}` +
`&scope=${scopesStr}`
return responses.redirectToExternalUrl(url)
}
const _oauthCallbackStep: oauthWizard.WizardStepHandler<bp.HandlerProps> = async ({
ctx,
client,
logger,
query,
responses,
setIntegrationIdentifier,
}) => {
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 environment = await getEnvironment({ client, ctx })
const credentials = await exchangeCodeForOAuthCredentials({ code, useDesk: useDeskOAuth(environment) })
await setOAuthCredentials({ client, ctx, credentials })
const hitlSetupWizardState = await client
.getState({ type: 'integration', name: 'hitlSetupWizard', id: ctx.integrationId })
.catch(() => null)
const enableHitl = hitlSetupWizardState?.state?.payload?.enableHitl ?? false
const hsClient = new HubspotClient({ accessToken: credentials.accessToken, client, ctx, logger })
const hubId = await hsClient.getHubId()
setIntegrationIdentifier(hubId)
await setPortalId({ client, ctx, portalId: hubId })
if (enableHitl) {
return responses.redirectToStep('hitl-inbox-id')
}
return responses.endWizard({ success: true })
}
const _hitlInboxIdStep: oauthWizard.WizardStepHandler<bp.HandlerProps> = async ({
ctx,
client,
logger,
selectedChoices,
responses,
}) => {
if (selectedChoices) {
const { state } = await client.getState({ type: 'integration', name: 'hitlSetupWizard', id: ctx.integrationId })
await client.setState({
type: 'integration',
name: 'hitlSetupWizard',
id: ctx.integrationId,
payload: { ...state.payload, selectedInboxIds: selectedChoices },
})
if (selectedChoices.length > 1) {
return responses.redirectToStep('hitl-default-inbox')
}
await client.setState({
type: 'integration',
name: 'hitlSetupWizard',
id: ctx.integrationId,
payload: { ...state.payload, selectedInboxIds: selectedChoices, defaultInboxId: selectedChoices[0] },
})
return responses.redirectToStep('hitl-setup')
}
const hitlClient = getHitlClient(ctx, client, logger)
let inboxes: Array<{ id: string; name: string }> = []
try {
inboxes = await hitlClient.listInboxes()
} catch {
logger.forBot().warn('Failed to fetch HubSpot inboxes')
}
const inboxTypeExplanation = 'Botpress HITL supports two inbox types:\n- Conversations Inbox\n- Help Desk'
if (inboxes.length === 0) {
return responses.displayButtons({
pageTitle: 'Select HubSpot Inboxes',
htmlOrMarkdownPageContents:
'No inboxes found in your HubSpot account.\n\n' +
inboxTypeExplanation +
'\n\nCreate an inbox in HubSpot, then click Refresh to continue.',
buttons: [{ label: 'Refresh', buttonType: 'primary', action: 'navigate', navigateToStep: 'hitl-inbox-id' }],
})
}
const previouslyConnectedInboxIds = await client
.getState({ type: 'integration', name: 'hitlConfig', id: ctx.integrationId })
.then((s) => Object.keys(s.state.payload.channelAccounts ?? {}))
.catch(() => [])
return responses.displayChoices({
pageTitle: 'Select HubSpot Inboxes',
htmlOrMarkdownPageContents:
'Select one or more inboxes where HITL conversations will be routed.\n\n' + inboxTypeExplanation,
choices: inboxes.map((inbox) => ({ label: `${inbox.name} (ID: ${inbox.id})`, value: inbox.id })),
nextStepId: 'hitl-inbox-id',
multiple: true,
defaultValues: previouslyConnectedInboxIds,
})
}
const _hitlDefaultInboxStep: oauthWizard.WizardStepHandler<bp.HandlerProps> = async ({
ctx,
client,
logger,
selectedChoice,
responses,
}) => {
const { state } = await client.getState({ type: 'integration', name: 'hitlSetupWizard', id: ctx.integrationId })
const selectedInboxIds: string[] = state.payload.selectedInboxIds ?? []
if (selectedChoice) {
await client.setState({
type: 'integration',
name: 'hitlSetupWizard',
id: ctx.integrationId,
payload: { ...state.payload, defaultInboxId: selectedChoice },
})
return responses.redirectToStep('hitl-setup')
}
const hitlClient = getHitlClient(ctx, client, logger)
let inboxes: Array<{ id: string; name: string }> = []
try {
inboxes = await hitlClient.listInboxes()
} catch {
logger.forBot().warn('Failed to fetch HubSpot inboxes for default selection')
}
const choices = selectedInboxIds.map((id) => {
const inbox = inboxes.find((i) => i.id === id)
return { label: `${inbox?.name ?? 'Unknown'} (ID: ${id})`, value: id }
})
const previousDefaultInboxId = await client
.getState({ type: 'integration', name: 'hitlConfig', id: ctx.integrationId })
.then((s) => s.state.payload.defaultInboxId)
.catch(() => undefined)
return responses.displayChoices({
pageTitle: 'Select Default Inbox',
htmlOrMarkdownPageContents:
'You selected multiple inboxes. Choose which one will be used by default when no inbox is specified.',
choices,
nextStepId: 'hitl-default-inbox',
defaultValues: previousDefaultInboxId ? [previousDefaultInboxId] : undefined,
})
}
const _hitlSetupStep: oauthWizard.WizardStepHandler<bp.HandlerProps> = async ({ ctx, client, logger, responses }) => {
const { state } = await client.getState({ type: 'integration', name: 'hitlSetupWizard', id: ctx.integrationId })
const { selectedInboxIds, defaultInboxId } = state.payload
if (!selectedInboxIds?.length || !defaultInboxId) {
return responses.endWizard({ success: false, errorMessage: 'Inbox selection not found in configuration state' })
}
const appId = bp.secrets.APP_ID
if (!appId) {
return responses.endWizard({ success: false, errorMessage: 'APP_ID secret is not configured' })
}
const channelId = await createHitlChannel({
ctx,
client,
logger,
appId,
developerApiKey: bp.secrets.DEVELOPER_API_KEY,
})
await client.setState({
type: 'integration',
name: 'hitlSetupWizard',
id: ctx.integrationId,
payload: { ...state.payload, channelId },
})
return responses.redirectToStep('creating-channel')
}
const _MAX_CHANNEL_ATTEMPTS = 10
const _creatingChannelStep: oauthWizard.WizardStepHandler<bp.HandlerProps> = async ({
ctx,
client,
logger,
query,
responses,
}) => {
const attempt = parseInt(query.get('wizattempt') ?? '0', 10)
if (attempt >= _MAX_CHANNEL_ATTEMPTS) {
return responses.endWizard({
success: false,
errorMessage: `Channel creation timed out after ${_MAX_CHANNEL_ATTEMPTS} attempts. Please try again.`,
})
}
const { state } = await client.getState({ type: 'integration', name: 'hitlSetupWizard', id: ctx.integrationId })
const { channelId, selectedInboxIds, defaultInboxId } = state.payload
if (!channelId || !selectedInboxIds?.length || !defaultInboxId) {
return responses.endWizard({ success: false, errorMessage: 'Missing channel or inbox configuration in state' })
}
const appId = bp.secrets.APP_ID
const hitlClient = getHitlClient(ctx, client, logger)
const { results } = await hitlClient.getCustomChannels(appId, bp.secrets.DEVELOPER_API_KEY)
const isAvailable = results.some((c) => c.id === channelId)
if (isAvailable) {
await connectHitlChannel({ ctx, client, logger, channelId, inboxIds: selectedInboxIds, defaultInboxId })
return responses.endWizard({ success: true })
}
const delaySecs = Math.pow(2, attempt)
const nextUrl = new URL(
`/oauth/wizard/creating-channel?state=${ctx.webhookId}&wizattempt=${attempt + 1}`,
process.env.BP_WEBHOOK_URL
)
return generateRawHtmlDialog({
pageTitle: 'Creating HubSpot Custom Channel',
bodyHtml: `
<meta http-equiv="refresh" content="${delaySecs};url=${nextUrl}">
<div class="d-flex flex-column align-items-center justify-content-center vh-100 gap-3">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="text-muted">Creating your HubSpot custom channel… (attempt ${attempt + 1} of ${_MAX_CHANNEL_ATTEMPTS})</p>
</div>
`,
})
}
export const buildOAuthWizard = (props: bp.HandlerProps) =>
new oauthWizard.OAuthWizardBuilder(props)
.addStep({ id: 'start', handler: _startStep })
.addStep({ id: 'oauth-redirect', handler: _oauthRedirectStep })
.addStep({ id: 'oauth-callback', handler: _oauthCallbackStep })
.addStep({ id: 'hitl-inbox-id', handler: _hitlInboxIdStep })
.addStep({ id: 'hitl-default-inbox', handler: _hitlDefaultInboxStep })
.addStep({ id: 'hitl-setup', handler: _hitlSetupStep })
.addStep({ id: 'creating-channel', handler: _creatingChannelStep })
.build()
@@ -0,0 +1 @@
export { handler } from './handler'