143 lines
4.8 KiB
TypeScript
143 lines
4.8 KiB
TypeScript
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 }
|
|
}
|