diff --git a/integrations/hubspot/definitions/states.ts b/integrations/hubspot/definitions/states.ts index 72fbd42d2d1..ca276b6aeea 100644 --- a/integrations/hubspot/definitions/states.ts +++ b/integrations/hubspot/definitions/states.ts @@ -91,9 +91,57 @@ const propertyCacheStates = { 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, ticketPipelineCache, companiesCache, ...propertyCacheStates, + hitlConfig, + hitlUserInfo, + hitlSetupWizard, } satisfies Record diff --git a/integrations/hubspot/hub.md b/integrations/hubspot/hub.md index abbeb7bfd26..0baf5943931 100644 --- a/integrations/hubspot/hub.md +++ b/integrations/hubspot/hub.md @@ -1,16 +1,17 @@ -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. +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 -To protect the sensitive data in your HubSpot workspace, this integration requires you to create and configure your own private HubSpot app. While we recognize this adds complexity to the setup process, it ensures your data remains secure. We're actively collaborating with HubSpot to streamline this into a one-click setup experience. In the meantime, please follow the steps below to manually configure the integration. +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. -### Manual configuration with a custom OAuth app +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: - - `oauth` - `crm.objects.contacts.read` - `crm.objects.contacts.write` - `tickets` @@ -22,7 +23,8 @@ To protect the sensitive data in your HubSpot workspace, this integration requir - `crm.objects.deals.read` - `crm.objects.deals.write` 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, enable _expanded object support_, then select the events you wish to subscribe to. Currently, the integration supports the following events: +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 @@ -31,9 +33,77 @@ To protect the sensitive data in your HubSpot workspace, this integration requir - Lead Deleted - Ticket Created - Ticket Deleted -7. You may now click the _Create App_ button to create your Legacy App. -8. From your app's settings page, navigate to the _Auth_ tab and copy the _Access Token_ and _Client Secret_. -9. Paste the _Access Token_ and _Client Secret_ in Botpress, then save the integration's configuration. + +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` +- `files` + +#### 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 diff --git a/integrations/hubspot/integration.definition.ts b/integrations/hubspot/integration.definition.ts index a6a49cea7fe..1d2855e98b6 100644 --- a/integrations/hubspot/integration.definition.ts +++ b/integrations/hubspot/integration.definition.ts @@ -1,11 +1,12 @@ 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: '5.3.3', + version: '6.0.0', readme: 'hub.md', icon: 'icon.svg', configuration: { @@ -19,13 +20,32 @@ export default new IntegrationDefinition({ 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 Access Token'), + 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('Hubspot Client Secret (used for webhook signature check)'), + .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.'), }), }, }, @@ -35,6 +55,21 @@ export default new IntegrationDefinition({ 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', @@ -46,6 +81,13 @@ export default new IntegrationDefinition({ // 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, @@ -55,4 +97,28 @@ export default new IntegrationDefinition({ 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', + }, + }, + }, + }, + }, +})) diff --git a/integrations/hubspot/linkTemplate.vrl b/integrations/hubspot/linkTemplate.vrl index ae384b34843..23372049f7a 100644 --- a/integrations/hubspot/linkTemplate.vrl +++ b/integrations/hubspot/linkTemplate.vrl @@ -1,29 +1,4 @@ -env = to_string!(.env) -clientId = if env == "production" { - "5eec0a6a-5be0-482f-8c5d-8ee139bcba43" -} else { - "eaebade6-ace5-452b-83ed-05418def8e5b" -} - -webhookUrl = to_string!(.webhookUrl) -redirectUri = "{{ webhookUrl }}/oauth" webhookId = to_string!(.webhookId) +webhookUrl = to_string!(.webhookUrl) -scopes = [ - "oauth", - "crm.objects.contacts.read", # to retrieve contacts - "crm.objects.contacts.write", # to create and update contacts - "tickets", # to retrieve ticket properties and create tickets - #"tickets.sensitive", # to retrieve sensitive ticket properties - #"tickets.highly_sensitive", # to retrieve highly sensitive ticket properties - "crm.objects.owners.read", # to retrieve and assign owners to tickets - "crm.objects.companies.read", # to retrieve and assign companies to tickets - "crm.objects.companies.write", # to create and update companies - "crm.objects.leads.read", # to retrieve leads - "crm.objects.leads.write", # to create and update leads - "crm.objects.deals.read", # to retrieve deals - "crm.objects.deals.write", # to create and update deals -] -scopesStr = encode_percent(join!(scopes, " ")) - -"https://app.hubspot.com/oauth/authorize?client_id={{ clientId }}&redirect_uri={{ redirectUri }}&state={{ webhookId }}&scope={{ scopesStr }}" +"{{ webhookUrl }}/oauth/wizard/start?state={{ webhookId }}" diff --git a/integrations/hubspot/package.json b/integrations/hubspot/package.json index 5651bb47b08..7f44cc61184 100644 --- a/integrations/hubspot/package.json +++ b/integrations/hubspot/package.json @@ -2,6 +2,9 @@ "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", @@ -11,10 +14,13 @@ "dependencies": { "@botpress/common": "workspace:*", "@botpress/sdk": "workspace:*", - "@hubspot/api-client": "^13.1.0" + "@hubspot/api-client": "^13.1.0", + "preact": "^10.26.6", + "preact-render-to-string": "^6.5.13" }, "devDependencies": { "@botpress/cli": "workspace:*", - "@botpress/sdk": "workspace:*" + "@botpress/sdk": "workspace:*", + "@types/node": "^22.16.4" } } diff --git a/integrations/hubspot/src/actions/hitl.ts b/integrations/hubspot/src/actions/hitl.ts new file mode 100644 index 00000000000..f1c5060c5e3 --- /dev/null +++ b/integrations/hubspot/src/actions/hitl.ts @@ -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 = { 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 {} +} diff --git a/integrations/hubspot/src/actions/index.ts b/integrations/hubspot/src/actions/index.ts index 6d8218201b1..c41923407ae 100644 --- a/integrations/hubspot/src/actions/index.ts +++ b/integrations/hubspot/src/actions/index.ts @@ -1,6 +1,7 @@ import * as companyActions from './company' import * as contactActions from './contact' import * as dealActions from './deal' +import { createUser, startHitl, stopHitl } from './hitl' import * as leadActions from './lead' import * as ticketActions from './ticket' import * as bp from '.botpress' @@ -11,4 +12,7 @@ export default { ...ticketActions, ...leadActions, ...companyActions, + createUser, + startHitl, + stopHitl, } as const satisfies bp.IntegrationProps['actions'] diff --git a/integrations/hubspot/src/auth.ts b/integrations/hubspot/src/auth.ts index 0989dbfe9e3..b42d50a4602 100644 --- a/integrations/hubspot/src/auth.ts +++ b/integrations/hubspot/src/auth.ts @@ -90,18 +90,15 @@ const _getOrRefreshOAuthAccessToken = async ({ client, ctx }: { client: bp.Clien } export const getAccessToken = async ({ client, ctx }: { client: bp.Client; ctx: bp.Context }) => { - let accessToken: string | undefined if (ctx.configurationType === 'manual') { - accessToken = ctx.configuration.accessToken - } else { - accessToken = await _getOrRefreshOAuthAccessToken({ client, ctx }) - } - - if (!accessToken) { - throw new RuntimeError('Access token not found in saved credentials') + const { accessToken } = ctx.configuration + if (!accessToken) { + throw new RuntimeError('Access token not found in saved credentials') + } + return accessToken } - return accessToken + return _getOrRefreshOAuthAccessToken({ client, ctx }) } export const getClientSecret = (ctx: bp.Context) => { diff --git a/integrations/hubspot/src/hitl/channel-handler.ts b/integrations/hubspot/src/hitl/channel-handler.ts new file mode 100644 index 00000000000..325f3371161 --- /dev/null +++ b/integrations/hubspot/src/hitl/channel-handler.ts @@ -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 { + 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 + } + } + }, + }, + }, +} diff --git a/integrations/hubspot/src/hitl/client.ts b/integrations/hubspot/src/hitl/client.ts new file mode 100644 index 00000000000..4efbd1d9ded --- /dev/null +++ b/integrations/hubspot/src/hitl/client.ts @@ -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 { + 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 = { + 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( + endpoint: string, + method: string = 'GET', + data: any = null, + params: any = {}, + retryConfig: RetryConfig = DEFAULT_RETRY_CONFIG + ): Promise> { + const accessToken = await getAccessToken({ client: this._bpClient, ctx: this._ctx }) + + const headers: Record = { + 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 { + const endpoint = `${HUBSPOT_API_BASE_URL}/conversations/v3/conversations/threads/${threadId}` + const response = await this._makeHitlRequest(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 { + const params: Record = { 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 = { 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 = { 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> { + 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> { + 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 { + 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> { + 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 { + const accessToken = await getAccessToken({ client: this._bpClient, ctx: this._ctx }) + + const fileResponse = await axios.get(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 { + 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 { + 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) diff --git a/integrations/hubspot/src/hitl/events/conversation-completed.ts b/integrations/hubspot/src/hitl/events/conversation-completed.ts new file mode 100644 index 00000000000..6cc616da58c --- /dev/null +++ b/integrations/hubspot/src/hitl/events/conversation-completed.ts @@ -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}`) + } +} diff --git a/integrations/hubspot/src/hitl/events/operator-assigned.ts b/integrations/hubspot/src/hitl/events/operator-assigned.ts new file mode 100644 index 00000000000..8617c2d3d39 --- /dev/null +++ b/integrations/hubspot/src/hitl/events/operator-assigned.ts @@ -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}`) + } +} diff --git a/integrations/hubspot/src/hitl/events/operator-replied.ts b/integrations/hubspot/src/hitl/events/operator-replied.ts new file mode 100644 index 00000000000..db0d70bc51b --- /dev/null +++ b/integrations/hubspot/src/hitl/events/operator-replied.ts @@ -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}`) + } +} diff --git a/integrations/hubspot/src/hitl/setup.ts b/integrations/hubspot/src/hitl/setup.ts new file mode 100644 index 00000000000..c9b991036ef --- /dev/null +++ b/integrations/hubspot/src/hitl/setup.ts @@ -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 { + 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 { + const { ctx, client, logger, channelId, inboxIds, defaultInboxId } = props + const hitlClient = getHitlClient(ctx, client, logger) + + const existingAccounts = await hitlClient.listChannelAccounts(channelId) + const channelAccounts: Record = {} + + 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 { + 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 { + 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`) +} diff --git a/integrations/hubspot/src/hitl/utils/conversation.ts b/integrations/hubspot/src/hitl/utils/conversation.ts new file mode 100644 index 00000000000..5151db8eab3 --- /dev/null +++ b/integrations/hubspot/src/hitl/utils/conversation.ts @@ -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 +} diff --git a/integrations/hubspot/src/hitl/utils/media.ts b/integrations/hubspot/src/hitl/utils/media.ts new file mode 100644 index 00000000000..f6d8208938d --- /dev/null +++ b/integrations/hubspot/src/hitl/utils/media.ts @@ -0,0 +1,32 @@ +const _EXT_ALIASES: Record = { 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 { + 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 } +} diff --git a/integrations/hubspot/src/hitl/utils/signature.ts b/integrations/hubspot/src/hitl/utils/signature.ts new file mode 100644 index 00000000000..f2bba6d6566 --- /dev/null +++ b/integrations/hubspot/src/hitl/utils/signature.ts @@ -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 + } +} diff --git a/integrations/hubspot/src/hubspot-api/hubspot-client.ts b/integrations/hubspot/src/hubspot-api/hubspot-client.ts index d9ca1a4ca1b..5d13f2d589d 100644 --- a/integrations/hubspot/src/hubspot-api/hubspot-client.ts +++ b/integrations/hubspot/src/hubspot-api/hubspot-client.ts @@ -581,7 +581,7 @@ export class HubspotClient { } if (!filters.length) { - throw new Error('No filters provided') + throw new sdk.RuntimeError('No filters provided') } const deals = await this._hsClient.crm.deals.searchApi.doSearch({ @@ -738,7 +738,7 @@ export class HubspotClient { } if (!filters.length) { - throw new Error('No filters provided') + throw new sdk.RuntimeError('No filters provided') } const leads = await this._hsClient.crm.objects.leads.searchApi.doSearch({ diff --git a/integrations/hubspot/src/index.ts b/integrations/hubspot/src/index.ts index d41feb6015a..7a92976d95b 100644 --- a/integrations/hubspot/src/index.ts +++ b/integrations/hubspot/src/index.ts @@ -1,4 +1,5 @@ import actions from './actions' +import { channels } from './hitl/channel-handler' import { register, unregister } from './setup' import { handler } from './webhook' import * as bp from '.botpress' @@ -7,6 +8,6 @@ export default new bp.Integration({ register, unregister, actions, - channels: {}, + channels, handler, }) diff --git a/integrations/hubspot/src/setup.ts b/integrations/hubspot/src/setup.ts index 857a2142a65..f55d1722be9 100644 --- a/integrations/hubspot/src/setup.ts +++ b/integrations/hubspot/src/setup.ts @@ -1,12 +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 }) => { +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, + } } -export const unregister: bp.IntegrationProps['unregister'] = async () => {} diff --git a/integrations/hubspot/src/webhook/handler.ts b/integrations/hubspot/src/webhook/handler.ts index 8116690fa66..b82c83360f1 100644 --- a/integrations/hubspot/src/webhook/handler.ts +++ b/integrations/hubspot/src/webhook/handler.ts @@ -1,28 +1,114 @@ +import { generateRedirection } from '@botpress/common/src/html-dialogs' +import * as oauthWizard from '@botpress/common/src/oauth-wizard' 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 (handlers.isOAuthCallback(props)) { - return await handlers.handleOAuthCallback(props) + + if (oauthWizard.isOAuthWizardUrl(req.path)) { + try { + return await buildOAuthWizard(props).handleRequest() + } catch (thrown: unknown) { + const errMsg = thrown instanceof Error ? thrown.message : String(thrown) + return generateRedirection(oauthWizard.getInterstitialUrl(false, errMsg)) + } + } + + if (req.path.startsWith('/oauth')) { + const modifiedProps = { ...props, req: { ...props.req, path: '/oauth/wizard/oauth-callback' } } + try { + return await buildOAuthWizard(modifiedProps).handleRequest() + } catch (thrown: unknown) { + const errMsg = thrown instanceof Error ? thrown.message : String(thrown) + return generateRedirection(oauthWizard.getInterstitialUrl(false, errMsg)) + } } - const validation = _validateRequestAuthentication(props) - if (validation.error) { - logger.error(`Error validating request: ${validation.message}`) - return { status: 401, body: validation.message } + if (_isCustomChannelEvent(req.body)) { + return await _handleHitlEvent(props) } - if (handlers.isBatchUpdateEvent(props)) { + // Global webhook subscriptions (conversation updates + CRM events) + if (handlers.isConversationEvent(props) || handlers.isBatchUpdateEvent(props)) { + const validation = _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 on '/${req.path}'`) - return { status: 404, body: 'No handler found' } + 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 = getClientSecret(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 = ({ req, ctx }: bp.HandlerProps) => { diff --git a/integrations/hubspot/src/webhook/handlers/conversation-events.ts b/integrations/hubspot/src/webhook/handlers/conversation-events.ts new file mode 100644 index 00000000000..218afa60deb --- /dev/null +++ b/integrations/hubspot/src/webhook/handlers/conversation-events.ts @@ -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 {} +} diff --git a/integrations/hubspot/src/webhook/handlers/index.ts b/integrations/hubspot/src/webhook/handlers/index.ts index 273424708f0..7392d3cdf17 100644 --- a/integrations/hubspot/src/webhook/handlers/index.ts +++ b/integrations/hubspot/src/webhook/handlers/index.ts @@ -1,2 +1,2 @@ -export * from './oauth-callback' export * from './batch-update' +export * from './conversation-events' diff --git a/integrations/hubspot/src/webhook/handlers/oauth-callback.ts b/integrations/hubspot/src/webhook/handlers/oauth-callback.ts deleted file mode 100644 index 4a9dfba3fd6..00000000000 --- a/integrations/hubspot/src/webhook/handlers/oauth-callback.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { RuntimeError } from '@botpress/sdk' -import { exchangeCodeForOAuthCredentials, setOAuthCredentials } from '../../auth' -import { HubspotClient } from '../../hubspot-api' -import * as bp from '.botpress' - -export const isOAuthCallback = (props: bp.HandlerProps): boolean => props.req.path.startsWith('/oauth') - -export const handleOAuthCallback: bp.IntegrationProps['handler'] = async ({ client, ctx, req, logger }) => { - const searchParams = new URLSearchParams(req.query) - const authorizationCode = searchParams.get('code') - - if (!authorizationCode) { - throw new RuntimeError('Code not present in OAuth callback request') - } - const credentials = await exchangeCodeForOAuthCredentials({ code: authorizationCode }) - await setOAuthCredentials({ - client, - ctx, - credentials, - }) - - const hsClient = new HubspotClient({ accessToken: credentials.accessToken, client, ctx, logger }) - const hubId = await hsClient.getHubId() - - await client.configureIntegration({ - identifier: hubId, - }) -} diff --git a/integrations/hubspot/src/webhook/handlers/oauth-wizard.ts b/integrations/hubspot/src/webhook/handlers/oauth-wizard.ts new file mode 100644 index 00000000000..e93ff7732bc --- /dev/null +++ b/integrations/hubspot/src/webhook/handlers/oauth-wizard.ts @@ -0,0 +1,336 @@ +import { generateRawHtmlDialog } from '@botpress/common/src/html-dialogs' +import * as oauthWizard from '@botpress/common/src/oauth-wizard' +import { exchangeCodeForOAuthCredentials, setOAuthCredentials } from '../../auth' +import { getHitlClient } from '../../hitl/client' +import { createHitlChannel, connectHitlChannel } from '../../hitl/setup' +import { HubspotClient } from '../../hubspot-api' +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', +] + +const HITL_SCOPES = [ + 'conversations.custom_channels.read', + 'conversations.custom_channels.write', + 'conversations.read', + 'conversations.write', + 'files', +] + +const _startStep: oauthWizard.WizardStepHandler = async ({ + selectedChoice, + client, + ctx, + responses, +}) => { + if (selectedChoice) { + const enableHitl = selectedChoice === 'with-hitl' + await client.setState({ + type: 'integration', + name: 'hitlSetupWizard', + id: ctx.integrationId, + payload: { enableHitl }, + }) + 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 = async ({ ctx, client, responses }) => { + const hitlSetupWizardState = await client + .getState({ type: 'integration', name: 'hitlSetupWizard', id: ctx.integrationId }) + .catch(() => null) + + const enableHitl = hitlSetupWizardState?.state?.payload?.enableHitl ?? false + const scopes = enableHitl ? [...CRM_SCOPES, ...HITL_SCOPES] : CRM_SCOPES + const scopesStr = encodeURIComponent(scopes.join(' ')) + + const url = + 'https://app.hubspot.com/oauth/authorize' + + `?client_id=${bp.secrets.CLIENT_ID}` + + `&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` + + `&state=${ctx.webhookId}` + + `&scope=${scopesStr}` + + return responses.redirectToExternalUrl(url) +} + +const _oauthCallbackStep: oauthWizard.WizardStepHandler = async ({ + ctx, + client, + logger, + query, + responses, +}) => { + const error = query.get('error') + if (error) { + const description = query.get('error_description') ?? '' + return responses.endWizard({ success: false, errorMessage: `OAuth error: ${error} - ${description}` }) + } + + const code = query.get('code') + if (!code) { + return responses.endWizard({ success: false, errorMessage: 'Authorization code not present in OAuth callback' }) + } + + const credentials = await exchangeCodeForOAuthCredentials({ code }) + 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() + await client.configureIntegration({ identifier: hubId }) + + if (enableHitl) { + return responses.redirectToStep('hitl-inbox-id') + } + + return responses.endWizard({ success: true }) +} + +const _hitlInboxIdStep: oauthWizard.WizardStepHandler = 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 = 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 = 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 = 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: ` + +
+
+ Loading... +
+

Creating your HubSpot custom channel… (attempt ${attempt + 1} of ${_MAX_CHANNEL_ATTEMPTS})

+
+ `, + }) +} + +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() diff --git a/integrations/hubspot/tsconfig.json b/integrations/hubspot/tsconfig.json index 32b8ead2675..c8703a2e4cf 100644 --- a/integrations/hubspot/tsconfig.json +++ b/integrations/hubspot/tsconfig.json @@ -1,8 +1,11 @@ { "extends": "../../tsconfig.json", "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "preact", "paths": { "*": ["./*"] }, "outDir": "dist", + "types": ["preact"], "experimentalDecorators": true, "emitDecoratorMetadata": true }, diff --git a/package.json b/package.json index d6632dae7cc..a3fcfcca5bc 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ }, "devDependencies": { "@aws-sdk/client-dynamodb": "^3.564.0", - "@botpress/api": "1.86.0", + "@botpress/api": "1.90.0", "@botpress/cli": "workspace:*", "@botpress/client": "workspace:*", "@botpress/sdk": "workspace:*", diff --git a/packages/cli/package.json b/packages/cli/package.json index 13d341ce84c..f574722e1fe 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@botpress/cli", - "version": "6.3.5", + "version": "6.4.0", "description": "Botpress CLI", "scripts": { "build": "pnpm run build:types && pnpm run bundle && pnpm run template:gen", @@ -27,8 +27,8 @@ "dependencies": { "@apidevtools/json-schema-ref-parser": "^11.7.0", "@botpress/chat": "0.5.5", - "@botpress/client": "1.40.0", - "@botpress/sdk": "6.5.0", + "@botpress/client": "1.41.0", + "@botpress/sdk": "6.6.0", "@bpinternal/const": "^0.1.0", "@bpinternal/tunnel": "^0.1.1", "@bpinternal/verel": "^0.2.0", diff --git a/packages/cli/templates/empty-bot/package.json b/packages/cli/templates/empty-bot/package.json index 720b4fdb418..14e3ad83107 100644 --- a/packages/cli/templates/empty-bot/package.json +++ b/packages/cli/templates/empty-bot/package.json @@ -5,8 +5,8 @@ }, "private": true, "dependencies": { - "@botpress/client": "1.40.0", - "@botpress/sdk": "6.5.0" + "@botpress/client": "1.41.0", + "@botpress/sdk": "6.6.0" }, "devDependencies": { "@types/node": "^22.16.4", diff --git a/packages/cli/templates/empty-integration/package.json b/packages/cli/templates/empty-integration/package.json index 5e36fe36a66..7cf19765679 100644 --- a/packages/cli/templates/empty-integration/package.json +++ b/packages/cli/templates/empty-integration/package.json @@ -6,8 +6,8 @@ }, "private": true, "dependencies": { - "@botpress/client": "1.40.0", - "@botpress/sdk": "6.5.0" + "@botpress/client": "1.41.0", + "@botpress/sdk": "6.6.0" }, "devDependencies": { "@types/node": "^22.16.4", diff --git a/packages/cli/templates/empty-plugin/package.json b/packages/cli/templates/empty-plugin/package.json index f7e02a797b5..e57ba7ee566 100644 --- a/packages/cli/templates/empty-plugin/package.json +++ b/packages/cli/templates/empty-plugin/package.json @@ -6,7 +6,7 @@ }, "private": true, "dependencies": { - "@botpress/sdk": "6.5.0" + "@botpress/sdk": "6.6.0" }, "devDependencies": { "@types/node": "^22.16.4", diff --git a/packages/cli/templates/hello-world/package.json b/packages/cli/templates/hello-world/package.json index 619a328a4da..0aeab584034 100644 --- a/packages/cli/templates/hello-world/package.json +++ b/packages/cli/templates/hello-world/package.json @@ -6,8 +6,8 @@ }, "private": true, "dependencies": { - "@botpress/client": "1.40.0", - "@botpress/sdk": "6.5.0" + "@botpress/client": "1.41.0", + "@botpress/sdk": "6.6.0" }, "devDependencies": { "@types/node": "^22.16.4", diff --git a/packages/cli/templates/webhook-message/package.json b/packages/cli/templates/webhook-message/package.json index 726ef8639eb..a87ac87acd2 100644 --- a/packages/cli/templates/webhook-message/package.json +++ b/packages/cli/templates/webhook-message/package.json @@ -6,8 +6,8 @@ }, "private": true, "dependencies": { - "@botpress/client": "1.40.0", - "@botpress/sdk": "6.5.0", + "@botpress/client": "1.41.0", + "@botpress/sdk": "6.6.0", "axios": "^1.6.8" }, "devDependencies": { diff --git a/packages/client/package.json b/packages/client/package.json index 4e6565a81d0..75490eefa49 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@botpress/client", - "version": "1.40.0", + "version": "1.41.0", "description": "Botpress Client", "main": "./dist/index.cjs", "module": "./dist/index.mjs", diff --git a/packages/common/src/html-dialogs/components/pages/select-dialog.tsx b/packages/common/src/html-dialogs/components/pages/select-dialog.tsx index 9e12bbbbf49..60f5afb6ea7 100644 --- a/packages/common/src/html-dialogs/components/pages/select-dialog.tsx +++ b/packages/common/src/html-dialogs/components/pages/select-dialog.tsx @@ -1,3 +1,5 @@ +import MarkdownDiv from '../markdown-div' + export default ({ pageTitle, helpText, @@ -5,6 +7,8 @@ export default ({ formFieldName, options, extraHiddenParams, + multiple, + defaultValues, }: { pageTitle: string helpText: string @@ -12,6 +16,8 @@ export default ({ formFieldName: string options: { label: string; value: string }[] extraHiddenParams: Record + multiple?: boolean + defaultValues?: string[] }) => { return (
@@ -22,16 +28,17 @@ export default ({ ))}
- + {helpText}
{options.map((option) => (