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