chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
@@ -0,0 +1,85 @@
import { z, ActionDefinition } from '@botpress/sdk'
export const companySchema = z.object({
id: z.string().title('Company ID').describe('The ID of the company'),
name: z.string().title('Name').describe('The name of the company'),
domain: z.string().title('Domain').describe('The domain of the company'),
createdAt: z.string().title('Created At').describe('Creation date of the company'),
updatedAt: z.string().title('Updated At').describe('Last time the company was updated'),
properties: z.record(z.string().nullable()).title('Properties').describe('The properties of the company'),
})
const searchCompany: ActionDefinition = {
title: 'Search Company',
description: 'Search for a company in HubSpot',
input: {
schema: z.object({
name: z.string().optional().title('Name').describe('The name of the company to search for'),
domain: z.string().optional().title('Domain').describe('The domain of the company to search for'),
}),
},
output: {
schema: z.object({
company: companySchema.optional().title('Company').describe('The company found'),
}),
},
}
const getCompany: ActionDefinition = {
title: 'Get Company',
description: 'Get a company from HubSpot by ID',
input: {
schema: z.object({
companyId: z.string().title('Company ID').describe('The ID of the company to get'),
propertiesToReturn: z
.array(z.string())
.optional()
.title('Properties to Return')
.describe(
'Additional properties to return (e.g., ["health_status", "industry"]). Default properties are always included.'
),
}),
},
output: {
schema: z.object({
company: companySchema.title('Company').describe('The fetched company'),
}),
},
}
const updateCompany: ActionDefinition = {
title: 'Update Company',
description: 'Update a company in HubSpot',
input: {
schema: z.object({
companyId: z.string().title('Company ID').describe('The ID of the company to update'),
properties: z
.array(
z.object({
name: z.string().title('Property Name').describe('The name of the property (e.g., "health_status")'),
value: z.string().title('Property Value').describe('The value of the property'),
})
)
.title('Properties')
.describe('Properties to update on the company'),
}),
},
output: {
schema: z.object({
company: companySchema
.extend({
// May not be returned by API
name: companySchema.shape.name.optional(),
domain: companySchema.shape.domain.optional(),
})
.title('Company')
.describe('The updated company'),
}),
},
}
export const actions = {
searchCompany,
getCompany,
updateCompany,
} as const
@@ -0,0 +1,231 @@
import { z, ActionDefinition } from '@botpress/sdk'
export const contactSchema = z.object({
id: z.string().title('Contact ID').describe('The ID of the contact'),
email: z.string().title('Email').describe('The email of the contact'),
phone: z.string().title('Phone').describe('The phone number of the contact'),
createdAt: z.string().title('Created At').describe('The date and time the contact was created'),
updatedAt: z.string().title('Updated At').describe('The date and time the contact was last updated'),
properties: z.record(z.string().nullable()).title('Properties').describe('The properties of the contact'),
})
export const propertyMetadataSchema = z.object({
name: z.string().title('Name').describe('The internal name of the property'),
label: z.string().title('Label').describe('The human-readable label of the property'),
type: z
.string()
.title('Type')
.describe('The data type of the property (e.g. string, number, date, datetime, enumeration, bool)'),
fieldType: z
.string()
.title('Field Type')
.describe('The form field type of the property (e.g. text, textarea, select, radio, checkbox)'),
groupName: z.string().title('Group Name').describe('The internal name of the property group'),
description: z.string().title('Description').describe('The description of the property'),
referencedObjectType: z
.string()
.optional()
.title('Referenced Object Type')
.describe('The type of object referenced by this property (e.g. OWNER), if any'),
})
const searchContact: ActionDefinition = {
title: 'Search Contact',
description: 'Search for a contact in HubSpot',
input: {
schema: z.object({
email: z.string().optional().title('Email').describe('The email of the contact to search for'),
phone: z.string().optional().title('Phone').describe('The phone number of the contact to search for'),
properties: z
.array(z.string())
.optional()
.title('Properties to Fetch')
.describe('The list of property names to fetch on the matching contact. Defaults to all properties.'),
}),
},
output: {
schema: z.object({
contact: contactSchema.optional().title('Contact').describe('The contact found, or undefined if not found'),
url: z
.string()
.optional()
.title('Contact URL')
.describe("The URL to the contact's page in the HubSpot UI, or undefined if no contact was found"),
}),
},
}
const listContactProperties: ActionDefinition = {
title: 'List Contact Properties',
description: 'List all available Hubspot contact properties with their metadata',
input: {
schema: z.object({}).title('Empty').describe('No input required'),
},
output: {
schema: z.object({
properties: z
.array(propertyMetadataSchema)
.title('Properties')
.describe('The contact properties defined in this Hubspot account'),
}),
},
}
const createContact: ActionDefinition = {
title: 'Create Contact',
description: 'Create a contact in HubSpot',
input: {
schema: z.object({
email: z.string().optional().title('Email').describe('The email of the contact'),
phone: z.string().optional().title('Phone').describe('The phone number of the contact'),
owner: z.string().optional().title('Owner').describe('The ID or email of the owner of the contact'),
companies: z
.array(
z.object({
idOrNameOrDomain: z
.string()
.title('Company ID, name or domain')
.describe('The ID, name or domain of the company'),
primary: z
.boolean()
.optional()
.title('Primary Contact')
.describe('Whether the contact is the primary contact for the company'),
})
)
.optional()
.title('Companies')
.describe('The companies to which the contact is associated'),
tickets: z
.array(z.string().title('Ticket ID').describe('The ID of the ticket'))
.optional()
.title('Tickets')
.describe('The tickets to which the contact is associated'),
properties: z
.array(
z.object({
name: z.string().title('Property Name').describe('The name of the property'),
value: z.string().title('Property Value').describe('The value of the property'),
})
)
.title('Additional Properties')
.optional()
.describe('Additional properties of the contact'),
}),
},
output: {
schema: z.object({
contact: contactSchema.title('Contact').describe('The created contact'),
}),
},
}
const getContact: ActionDefinition = {
title: 'Get Contact',
description: 'Get a contact from HubSpot',
input: {
schema: z.object({
contactIdOrEmail: z.string().title('Contact ID or Email').describe('The ID or email of the contact to get'),
}),
},
output: {
schema: z.object({
contact: contactSchema.title('Contact').describe('The fetched contact'),
url: z.string().title('Contact URL').describe("The URL to the contact's page in the HubSpot UI"),
}),
},
}
const updateContact: ActionDefinition = {
title: 'Update Contact',
description: 'Update a contact in HubSpot',
input: {
schema: z.object({
contactIdOrEmail: z.string().title('Contact ID or Email').describe('The ID or email of the contact to update'),
email: z.string().optional().title('Email').describe('The new email of the contact'),
phone: z.string().optional().title('Phone').describe('The new phone number of the contact'),
properties: z
.array(
z.object({
name: z.string().title('Property Name').describe('The name of the property'),
value: z.string().title('Property Value').describe('The new value of the property'),
})
)
.title('Additional Properties')
.optional()
.describe('Additional properties of the contact'),
}),
},
output: {
schema: z.object({
contact: contactSchema
.extend({
// May not be returned by API
phone: contactSchema.shape.phone.optional(),
email: contactSchema.shape.email.optional(),
})
.title('Contact')
.describe('The updated contact'),
}),
},
}
const deleteContact: ActionDefinition = {
title: 'Delete Contact',
description: 'Delete a contact in HubSpot',
input: {
schema: z.object({
contactId: z.string().title('Contact ID').describe('The ID of the contact to delete'),
}),
},
output: {
schema: z.object({}).title('Empty').describe('Empty output'),
},
}
const listContacts: ActionDefinition = {
title: 'List Contacts',
description: 'List contacts in HubSpot',
input: {
schema: z.object({
meta: z
.object({
nextToken: z
.string()
.optional()
.title('Pagination token')
.describe('The token to get the next page of contacts'),
})
.title('Metadata')
.describe('Metadata containing pagination information'),
}),
},
output: {
schema: z.object({
contacts: z
.array(contactSchema.title('Contact').describe('A contact'))
.title('Contacts')
.describe('The contacts found'),
meta: z
.object({
nextToken: z
.string()
.optional()
.title('Pagination token')
.describe('The token to get the next page of contacts'),
})
.title('Metadata')
.describe('Metadata containing pagination information'),
}),
},
}
export const actions = {
searchContact,
createContact,
getContact,
updateContact,
deleteContact,
listContacts,
listContactProperties,
} as const
@@ -0,0 +1,117 @@
import { z, ActionDefinition } from '@botpress/sdk'
export const dealSchema = z.object({
id: z.string().title('Deal ID').describe('The ID of the deal'),
name: z.string().title('Name').describe('The name of the deal'),
createdAt: z.string().title('Created At').describe('Creation date of the deal'),
updatedAt: z.string().title('Updated At').describe('Last time the deal was updated'),
properties: z.record(z.string().nullable()).title('Properties').describe('The properties of the deal'),
})
const searchDeal: ActionDefinition = {
title: 'Search Deal',
description: 'Search for a deal in HubSpot',
input: {
schema: z.object({
name: z.string().optional().title('Name').describe('The name of the deal to search for'),
}),
},
output: {
schema: z.object({
deal: dealSchema.optional().title('Deal').describe('The deal found, or undefined if not found'),
}),
},
}
const createDeal: ActionDefinition = {
title: 'Create Deal',
description: 'Create a deal in HubSpot',
input: {
schema: z.object({
name: z.string().title('Name').describe('The name of the deal'),
properties: z
.array(
z.object({
name: z.string().title('Property Name').describe('The name of the property'),
value: z.string().title('Property Value').describe('The value of the property'),
})
)
.optional()
.title('Properties')
.describe('The properties of the deal'),
}),
},
output: {
schema: z.object({
deal: dealSchema.title('Deal').describe('The created deal'),
}),
},
}
const getDeal: ActionDefinition = {
title: 'Get Deal',
description: 'Get a deal from HubSpot',
input: {
schema: z.object({
dealId: z.string().title('Deal ID').describe('The ID of the deal to get'),
}),
},
output: {
schema: z.object({
deal: dealSchema.title('Deal').describe('The fetched deal'),
}),
},
}
const updateDeal: ActionDefinition = {
title: 'Update Deal',
description: 'Update a deal in HubSpot',
input: {
schema: z.object({
dealId: z.string().title('Deal ID').describe('The ID of the deal to update'),
name: z.string().optional().title('Name').describe('The name of the deal'),
properties: z
.array(
z.object({
name: z.string().title('Property Name').describe('The name of the property'),
value: z.string().title('Property Value').describe('The value of the property'),
})
)
.optional()
.title('Properties')
.describe('The properties of the deal'),
}),
},
output: {
schema: z.object({
deal: dealSchema
.extend({
// May not be returned by API
name: dealSchema.shape.name.optional(),
})
.title('Deal')
.describe('The updated deal'),
}),
},
}
const deleteDeal: ActionDefinition = {
title: 'Delete Deal',
description: 'Delete a deal in HubSpot',
input: {
schema: z.object({
dealId: z.string().title('Deal ID').describe('The ID of the deal to delete'),
}),
},
output: {
schema: z.object({}).title('Empty').describe('Empty output'),
},
}
export const actions = {
searchDeal,
createDeal,
getDeal,
updateDeal,
deleteDeal,
} as const
@@ -0,0 +1,20 @@
import { z, ActionDefinition } from '@botpress/sdk'
const getFileUrl: ActionDefinition = {
title: 'Get File URL',
description: 'Get a URL to access a file stored in Hubspot Files',
input: {
schema: z.object({
fileName: z.string().title('File path').describe('The path to the Hubspot file'),
}),
},
output: {
schema: z.object({
url: z.string().optional().title('URL').describe('The URL of the file, or undefined if not available'),
}),
},
}
export const actions = {
getFileUrl,
} as const
@@ -0,0 +1,17 @@
import { actions as companyActions } from './company'
import { actions as contactActions } from './contact'
import { actions as dealActions } from './deal'
import { actions as fileActions } from './file'
import { actions as leadActions } from './lead'
import { actions as ownerActions } from './owner'
import { actions as ticketActions } from './ticket'
export const actions = {
...contactActions,
...ticketActions,
...dealActions,
...leadActions,
...companyActions,
...ownerActions,
...fileActions,
} as const
@@ -0,0 +1,120 @@
import { z, ActionDefinition } from '@botpress/sdk'
export const leadSchema = z.object({
id: z.string().title('Lead ID').describe('The ID of the lead'),
name: z.string().title('Name').describe('The name of the lead'),
createdAt: z.string().title('Created At').describe('Creation date of the lead'),
updatedAt: z.string().title('Updated At').describe('Last time the lead was updated'),
properties: z.record(z.string().nullable()).title('Properties').describe('The properties of the lead'),
})
const searchLead: ActionDefinition = {
title: 'Search Lead',
description: 'Search for a lead in HubSpot',
input: {
schema: z.object({
name: z.string().optional().title('Name').describe('The name of the lead to search for'),
}),
},
output: {
schema: z.object({
lead: leadSchema.optional().title('Lead').describe('The lead found, or undefined if not found'),
}),
},
}
const createLead: ActionDefinition = {
title: 'Create Lead',
description: 'Create a lead in HubSpot',
input: {
schema: z.object({
name: z.string().title('Name').describe('The name of the lead'),
contact: z
.string()
.title('Contact')
.describe('The contact to associate the lead with. Can be an email address or contact ID'),
properties: z
.array(
z.object({
name: z.string().title('Property Name').describe('The name of the property'),
value: z.string().title('Property Value').describe('The value of the property'),
})
)
.optional()
.title('Properties')
.describe('The properties of the lead'),
}),
},
output: {
schema: z.object({
lead: leadSchema.title('Lead').describe('The created lead'),
}),
},
}
const getLead: ActionDefinition = {
title: 'Get Lead',
description: 'Get a lead from HubSpot',
input: {
schema: z.object({
leadId: z.string().title('Lead ID').describe('The ID of the lead to get'),
}),
},
output: {
schema: z.object({
lead: leadSchema.title('Lead').describe('The fetched lead'),
}),
},
}
const updateLead: ActionDefinition = {
title: 'Update Lead',
description: 'Update a lead in HubSpot',
input: {
schema: z.object({
leadId: z.string().title('Lead ID').describe('The ID of the lead to update'),
name: z.string().optional().title('Name').describe('The name of the lead'),
properties: z
.array(
z.object({
name: z.string().title('Property Name').describe('The name of the property'),
value: z.string().title('Property Value').describe('The value of the property'),
})
)
.optional()
.title('Properties')
.describe('The properties of the lead'),
}),
},
output: {
schema: z.object({
lead: leadSchema
.extend({
// May not be returned by API
name: leadSchema.shape.name.optional(),
})
.title('Lead')
.describe('The updated lead'),
}),
},
}
const deleteLead: ActionDefinition = {
title: 'Delete Lead',
description: 'Delete a lead in HubSpot',
input: {
schema: z.object({
leadId: z.string().title('Lead ID').describe('The ID of the lead to delete'),
}),
},
output: {
schema: z.object({}).title('Empty').describe('Empty output'),
},
}
export const actions = {
searchLead,
createLead,
getLead,
updateLead,
deleteLead,
} as const
@@ -0,0 +1,32 @@
import { z, ActionDefinition } from '@botpress/sdk'
export const ownerSchema = z.object({
id: z.string().title('Owner ID').describe('The ID of the owner'),
email: z.string().optional().title('Email').describe('The email of the owner'),
firstName: z.string().optional().title('First Name').describe('The first name of the owner'),
lastName: z.string().optional().title('Last Name').describe('The last name of the owner'),
userId: z.number().optional().title('User ID').describe('The Hubspot user ID associated with the owner'),
type: z.string().title('Type').describe('The type of owner (e.g. PERSON, QUEUE)'),
archived: z.boolean().title('Archived').describe('Whether the owner is archived'),
createdAt: z.string().title('Created At').describe('The date and time the owner was created'),
updatedAt: z.string().title('Updated At').describe('The date and time the owner was last updated'),
})
const getOwner: ActionDefinition = {
title: 'Get Owner',
description: 'Get a Hubspot owner (user) by ID. Used to resolve owner references on contacts, deals, etc.',
input: {
schema: z.object({
ownerId: z.string().title('Owner ID').describe('The ID of the owner to fetch'),
}),
},
output: {
schema: z.object({
owner: ownerSchema.optional().title('Owner').describe('The owner found, or undefined if not found'),
}),
},
}
export const actions = {
getOwner,
} as const
@@ -0,0 +1,67 @@
import { ActionDefinition, z } from '@botpress/sdk'
export const ticketSchema = z.object({
id: z.string().title('Ticket ID').describe('The ID of the ticket'),
subject: z.string().title('Ticket name').describe('Short summary of ticket'),
category: z.string().title('Category').describe('Main reason customer reached out for help'),
description: z.string().title('Ticket description').describe('Description of the ticket'),
priority: z.string().title('Priority').describe('The level of attention needed on the ticket'),
source: z.string().title('Source').describe('The original source of the ticket'),
properties: z.record(z.string().nullable()).title('Properties').describe('The properties of the ticket'),
})
const createTicket: ActionDefinition = {
title: 'Create Ticket',
description: 'Create a ticket in HubSpot',
input: {
schema: z.object({
subject: z.string().title('Ticket name').describe('Short summary of ticket'),
category: z.string().optional().title('Category').describe('Main reason customer reached out for help'),
description: z.string().optional().title('Ticket description').describe('Description of the ticket'),
priority: z.string().optional().title('Priority').describe('The level of attention needed on the ticket'),
source: z.string().optional().title('Source').describe('The original source of the ticket'),
pipeline: z
.string()
.title('Pipeline')
.describe('The pipeline that contains this ticket. Can be a name or internal ID'),
pipelineStage: z
.string()
.title('Ticket status')
.describe('The pipeline stage that contains this ticket. Can be a name or internal ID'),
ticketOwner: z
.string()
.optional()
.title('Ticket owner')
.describe('User the ticket is assigned to. Can be an email address or user ID'),
requester: z
.string()
.optional()
.title('Customer')
.describe('The ticket requester. Can be an email address or contact ID'),
company: z
.string()
.optional()
.title('Company')
.describe('The company associated with the ticket. Can be a name, domain, or company ID'),
properties: z
.array(
z.object({
name: z.string().title('Property Name').describe('The name of the property'),
value: z.string().title('Property Value').describe('The value of the property'),
})
)
.optional()
.title('Additional Properties')
.describe('Additional ticket properties'),
}),
},
output: {
schema: z.object({
ticket: ticketSchema.title('Ticket').describe('The created ticket'),
}),
},
}
export const actions = {
createTicket,
} as const
@@ -0,0 +1,69 @@
import { z, EventDefinition } from '@botpress/sdk'
const contactCreated = {
title: 'Contact Created',
description: 'A new contact has been created in HubSpot.',
schema: z.object({
contactId: z.string().title('Contact ID').describe('The ID of the created contact'),
name: z.string().optional().title('Name').describe('The name of the created contact'),
email: z.string().optional().title('Email').describe('The email of the created contact'),
phoneNumber: z.string().optional().title('Phone Number').describe('The phone number of the created contact'),
}),
}
const contactDeleted = {
title: 'Contact Deleted',
description: 'A contact has been deleted in HubSpot.',
schema: z.object({
contactId: z.string().title('Contact ID').describe('The ID of the deleted contact'),
}),
}
const companyCreated = {
title: 'Company Created',
description: 'A new company has been created in HubSpot.',
schema: z.object({
companyId: z.string().title('Company ID').describe('The ID of the created company'),
name: z.string().optional().title('Name').describe('The name of the created company'),
domain: z.string().optional().title('Domain').describe('The domain of the created company'),
phoneNumber: z.string().optional().title('Phone Number').describe('The phone number of the created company'),
}),
}
const companyDeleted = {
title: 'Company Deleted',
description: 'A company has been deleted in HubSpot.',
schema: z.object({
companyId: z.string().title('Company ID').describe('The ID of the deleted company'),
}),
}
const ticketCreated = {
title: 'Ticket Created',
description: 'A new ticket has been created in HubSpot.',
schema: z.object({
ticketId: z.string().title('Ticket ID').describe('The ID of the created ticket'),
subject: z.string().optional().title('Subject').describe('The subject of the created ticket'),
priority: z.string().optional().title('Priority').describe('The priority of the created ticket'),
category: z.string().optional().title('Category').describe('The category of the created ticket'),
pipeline: z.string().title('Pipeline').describe('The pipeline of the created ticket'),
stage: z.string().title('Stage').describe('The stage of the created ticket'),
}),
}
const ticketDeleted = {
title: 'Ticket Deleted',
description: 'A ticket has been deleted in HubSpot.',
schema: z.object({
ticketId: z.string().title('Ticket ID').describe('The ID of the deleted ticket'),
}),
}
export const events = {
contactCreated,
contactDeleted,
companyCreated,
companyDeleted,
ticketCreated,
ticketDeleted,
} as const satisfies Record<string, EventDefinition>
@@ -0,0 +1,3 @@
export { actions } from './actions'
export { states } from './states'
export { events } from './events'
+167
View File
@@ -0,0 +1,167 @@
import { z, StateDefinition } from '@botpress/sdk'
const oauthCredentials = {
type: 'integration',
schema: z.object({
accessToken: z.string().title('Access Token').describe('The access token for the HubSpot integration'),
refreshToken: z.string().title('Refresh Token').describe('The refresh token for the HubSpot integration'),
expiresAtSeconds: z.number().title('Expires At').describe('The timestamp in seconds when the access token expires'),
}),
} satisfies StateDefinition
const hubInfo = {
type: 'integration',
schema: z.object({
portalId: z.string().title('Portal ID').describe('The HubSpot portal (hub) ID for the connected account'),
}),
} satisfies StateDefinition
const environment = {
type: 'integration',
schema: z.object({
env: z
.enum(['preview', 'production'])
.title('Environment')
.describe('The environment where the integration is installed'),
source: z.string().optional().title('Source').describe('The source of the OAuth request, eg: "desk"'),
}),
} satisfies StateDefinition
const ticketPipelineCache = {
type: 'integration',
schema: z.object({
pipelines: z
.record(
z.object({
label: z.string().title('Label').describe('The label of the pipeline'),
stages: z
.record(
z.object({
label: z.string().title('Label').describe('The label of the pipeline stage'),
})
)
.title('Stages')
.describe('A mapping of pipeline stage ids (string) to pipeline stages'),
})
)
.title('Pipelines')
.describe('A mapping of pipeline ids (string) to pipelines'),
}),
} satisfies StateDefinition
const companiesCache = {
type: 'integration',
schema: z.object({
companies: z
.record(
z.object({
name: z.string().optional().title('Name').describe('The name of the company'),
domain: z.string().optional().title('Domain').describe('The domain of the company'),
})
)
.title('Companies')
.describe('A mapping of company ids (string) to company details'),
}),
} satisfies StateDefinition
const PROPERTY_TYPES = [
'bool',
'enumeration',
'date',
'datetime',
'string',
'number',
'object_coordinates',
'json',
'phone_number',
] as const
export const propertyTypeSchema = z.enum(PROPERTY_TYPES)
export type PropertyType = z.infer<typeof propertyTypeSchema>
const propertyCacheStateDefinition = {
type: 'integration',
schema: z.object({
properties: z
.record(
z.object({
label: z.string().title('Label').describe('The label of the property'),
type: propertyTypeSchema,
hubspotDefined: z.boolean().title('HubSpot Defined').describe('Whether the property is defined by HubSpot'),
options: z
.array(z.string())
.optional()
.title('Options')
.describe('The options of the property if it is an enumeration'),
})
)
.title('Properties')
.describe('A mapping of property names (string) to property details'),
}),
} satisfies StateDefinition
export type CrmObjectType = 'ticket' | 'deal' | 'contact' | 'lead' | 'company'
const propertyCacheStates = {
ticketPropertyCache: propertyCacheStateDefinition,
dealPropertyCache: propertyCacheStateDefinition,
contactPropertyCache: propertyCacheStateDefinition,
leadPropertyCache: propertyCacheStateDefinition,
companyPropertyCache: propertyCacheStateDefinition,
} satisfies Record<`${CrmObjectType}PropertyCache`, StateDefinition>
const hitlConfig = {
type: 'integration' as const,
schema: z.object({
channelId: z.string().title('Channel ID').describe('The HubSpot custom channel ID'),
defaultInboxId: z
.string()
.title('Default Inbox ID')
.describe('The inbox used when no inboxId is specified in startHitl'),
channelAccounts: z
.record(z.string())
.title('Channel Accounts')
.describe('Map of inboxId to channelAccountId for all connected inboxes'),
}),
} satisfies StateDefinition
const hitlUserInfo = {
type: 'user' as const,
schema: z.object({
name: z.string().title('Name').describe('The display name of the user'),
contactIdentifier: z.string().title('Contact Identifier').describe('Email address or phone number of the user'),
contactType: z
.enum(['email', 'phone'])
.title('Contact Type')
.describe('Whether the identifier is an email or phone number'),
}),
} satisfies StateDefinition
const hitlSetupWizard = {
type: 'integration' as const,
schema: z.object({
enableHitl: z.boolean().title('Enable HITL').describe('Whether HITL is enabled for this integration'),
selectedInboxIds: z
.array(z.string())
.optional()
.title('Selected Inbox IDs')
.describe('Inboxes selected during wizard setup'),
defaultInboxId: z.string().optional().title('Default Inbox ID').describe('The inbox used by default in startHitl'),
channelId: z
.string()
.optional()
.title('Channel ID')
.describe('HubSpot custom channel ID, saved between wizard steps'),
}),
} satisfies StateDefinition
export const states = {
oauthCredentials,
hubInfo,
environment,
ticketPipelineCache,
companiesCache,
...propertyCacheStates,
hitlConfig,
hitlUserInfo,
hitlSetupWizard,
} satisfies Record<string, StateDefinition>
+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+2
View File
@@ -0,0 +1,2 @@
body = parse_json!(.body)
to_string!(body[0].portalId)
+145
View File
@@ -0,0 +1,145 @@
The HubSpot integration allows you to connect your Botpress chatbot with HubSpot, a leading CRM and marketing automation platform. With this integration, your chatbot can manage contacts, tickets, and more directly within HubSpot, enabling seamless automation of sales, marketing, and support workflows. It also supports Human-in-the-Loop (HITL), allowing conversations to be escalated to HubSpot agents in real time.
## Configuration
The recommended way to configure this integration is via the built-in **OAuth wizard**, which handles authorization and configuration automatically. You can start the wizard directly from the integration's configuration page in Botpress by clicking on the "Connect/Authorize" button.
For advanced users who need full control over their HubSpot app, a **manual configuration** option is also available. Follow the steps below to set it up.
### Manual configuration with a custom private app
1. Install the integration in your bot and copy the webhook URL. This URL starts with `https://webhook.botpress.cloud/`.
2. From your HubSpot settings dashboard, navigate to _Account Management_ &gt; _Integrations_ &gt; _Legacy Apps_.
3. Create a new Legacy App and make it private.
4. Under the _Scopes_ tab, please add the following scopes:
- `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`
5. Under the _Webhooks_ tab, paste your webhook URL, set _Event Throttling_ to 1, and click _Create Subscription_.
6. You may now optionally subscribe to webhook events. In the _Create new webhook subscriptions_ dialog, **you must enable _expanded object support_** before selecting the events you wish to subscribe to. Currently, the integration supports the following events:
- Company Created
- Company Deleted
- Contact Created
- Contact Deleted
- Lead Created
- Lead Deleted
- Ticket Created
- Ticket Deleted
7. You may now click the _Create App_ button to create your Legacy Private App.
8. From your app's settings page, navigate to the _Auth_ tab and copy the _Access Token_ and _Client Secret_. In the Botpress integration configuration, paste them and save.
| Field | Value |
| ------------- | ---------------------------------------------------------------- |
| Access Token | The Access Token from your Private App |
| Client Secret | Your app's Client Secret (used for webhook signature validation) |
### HITL (Human-in-the-Loop) manual configuration
If you already have the CRM integration configured, you can reuse the same Private App — just add the HITL scopes and retrieve a few additional values.
#### 1. Add HITL Scopes to Your Private App
In your HubSpot settings, open your existing Private App and add the following scopes (in addition to the CRM scopes already configured):
- `conversations.custom_channels.read`
- `conversations.custom_channels.write`
- `conversations.read`
- `conversations.write`
#### 2. Add a Webhook Subscription
Under the **Webhooks** tab, subscribe to:
- `conversation.propertyChange` (for agent assignment and conversation status changes), select all properties.
#### 3. Click _Commit Changes_ to save the updated scopes and webhook subscriptions.
#### 4. Get Your App ID and Developer API Key
- **App ID**: Open your private App in HubSpot again — the App ID is in the URL (e.g., `https://app.hubspot.com/private-apps/ACCOUNT_ID/36900466`).
- **Developer API Key**: In your HubSpot Dashboard, navigate to _Development_ > _Keys_ > _Developer API Key_ and copy or generate your key.
#### 5. Retrieve Your Help Desk or Inbox IDs
You need the ID of the HubSpot inbox (or Help Desk) where HITL conversations will be routed. Use the Access Token from your Private App (found in the _Auth_ tab) to call the inboxes API.
You can run this in a terminal, or use a tool like [Postman](https://www.postman.com/) or [ReqBin](https://reqbin.com/). Replace `YOUR_ACCESS_TOKEN` with the token from your Private App:
```bash
curl --location 'https://api.hubapi.com/conversations/v3/conversations/inboxes' \
--header 'Authorization: Bearer YOUR_ACCESS_TOKEN'
```
The response will list all inboxes in your HubSpot account. Look for the entry matching your target inbox and copy its `id`. For Help Desk, look for `"type": "HELP_DESK"`. For a standard inbox, look for `"type": "INBOX"`.
```json
{
"results": [
{ "id": "1431487401", "name": "Help Desk", "type": "HELP_DESK" },
{ "id": "1234567890", "name": "Sales Inbox", "type": "INBOX" }
]
}
```
You can connect multiple inboxes — the first one will be used as the default.
#### 6. Configure Botpress
Fill in the following fields in your Botpress integration configuration:
| Field | Value |
| ----------------- | ------------------------------------------------------------------------- |
| App ID | Your app's App ID from step 4 |
| Developer API Key | Your developer API key from step 4 |
| Inbox IDs | One or more inbox or Help Desk IDs from step 5. The first is the default. |
Save the configuration. After saving, it may take **over a minute** for the HubSpot custom channel to connect. Do not refresh or close the page during this time.
## Migrating from 4.x to 5.x
Version 5.x addresses issues that could cause your bot to crash during certain operations. The search actions (`Search Contact`, `Search Deal`, `Search Lead`) now have optional outputs — they return undefined instead of throwing an error when a resource is not found. Make sure to handle this in your bots logic to avoid unexpected behavior.
## Migrating from 3.x to 4.x
### Default properties
The default properties returned when searching for or retrieving a CRM object may have changed. If your bot relied on a property that is now missing, add the property's name to the `properties` input parameter of the get or search action for the relevant CRM object.
### Unified output structure
- All actions now return the CRM object relevant to the action directly within the output schema, ensuring consistency across all action responses. If an object is updated, some properties may not appear in the output. For example, the `createTicket` action now returns an object with the following structure:
```ts
type CreateTicketOutput = {
ticket: {
id: string
subject: string
category: string
description: string
priority: string
source: string
properties: Record<string, string>
}
}
```
### Deal and Lead
- There are now dedicated input parameters for setting and updating the names of deals or leads. The name is also included as a dedicated property of the CRM object in the output.
- You can now specify properties to include in the output when searching for or retrieving a deal or lead.
### Ticket
- The `category`, `priority`, and `source` input parameters can now accept any valid string that corresponds to a valid value in your HubSpot account.
- The `linearTicketUrl` input parameter has been removed, as it may not be a valid property in all HubSpot accounts. If your bot was setting this property, set it as an additional property in the `property` input parameter.
+1
View File
@@ -0,0 +1 @@
<svg height="2500" viewBox="6.20856283 .64498824 244.26943717 251.24701176" width="2500" xmlns="http://www.w3.org/2000/svg"><path d="m191.385 85.694v-29.506a22.722 22.722 0 0 0 13.101-20.48v-.677c0-12.549-10.173-22.722-22.721-22.722h-.678c-12.549 0-22.722 10.173-22.722 22.722v.677a22.722 22.722 0 0 0 13.101 20.48v29.506a64.342 64.342 0 0 0 -30.594 13.47l-80.922-63.03c.577-2.083.878-4.225.912-6.375a25.6 25.6 0 1 0 -25.633 25.55 25.323 25.323 0 0 0 12.607-3.43l79.685 62.007c-14.65 22.131-14.258 50.974.987 72.7l-24.236 24.243c-1.96-.626-4-.959-6.057-.987-11.607.01-21.01 9.423-21.007 21.03.003 11.606 9.412 21.014 21.018 21.017 11.607.003 21.02-9.4 21.03-21.007a20.747 20.747 0 0 0 -.988-6.056l23.976-23.985c21.423 16.492 50.846 17.913 73.759 3.562 22.912-14.352 34.475-41.446 28.985-67.918-5.49-26.473-26.873-46.734-53.603-50.792m-9.938 97.044a33.17 33.17 0 1 1 0-66.316c17.85.625 32 15.272 32.01 33.134.008 17.86-14.127 32.522-31.977 33.165" fill="#ff7a59"/></svg>

After

Width:  |  Height:  |  Size: 969 B

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