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