chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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']
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user