Files
botpress--botpress/integrations/bigcommerce-sync/src/index.ts
T
2026-07-13 13:34:48 +08:00

505 lines
14 KiB
TypeScript

import { Client } from '@botpress/client'
import actions from './actions'
import { executeBackgroundSync } from './actions/sync-products'
import { getBigCommerceClient, BigCommerceClient } from './client'
import { PRODUCT_TABLE_SCHEMA, PRODUCTS_TABLE_NAME as PRODUCT_TABLE } from './schemas/products'
import * as bp from '.botpress'
// this client is necessary for table operations
const getVanillaClient = (client: bp.Client): Client => client._inner
export type BigCommerceProductImage = {
is_thumbnail: boolean
url_standard: string
}
type WebhookType = 'created' | 'updated' | 'deleted'
const determineWebhookTypeFromScope = (scope: string): WebhookType | undefined => {
if (scope.includes('created')) {
return 'created'
} else if (scope.includes('updated')) {
return 'updated'
} else if (scope.includes('deleted')) {
return 'deleted'
}
return undefined
}
const determineWebhookTypeFromProducer = (webhookData: WebhookDataWithProducer): WebhookType | undefined => {
if (webhookData.data && webhookData.data.type) {
return webhookData.data.type.toLowerCase() as WebhookType
}
return undefined
}
const isBigCommerceWebhook = (headers: Record<string, string | string[] | undefined>): boolean => {
return !!(
(headers['webhook-id'] && headers['webhook-signature'] && headers['webhook-timestamp']) ||
Object.keys(headers).some(
(key) => key.toLowerCase().includes('bigcommerce') || key.toLowerCase().includes('bc-webhook')
)
)
}
type WebhookData = {
data?: {
id?: string | number
entity_id?: string | number
}
id?: string | number
}
type WebhookDataWithProducer = WebhookData & {
producer?: string
data?: {
id?: string | number
entity_id?: string | number
type?: string
}
}
const extractProductId = (webhookData: WebhookData): string | undefined => {
if (webhookData?.data?.id) return String(webhookData.data.id)
if (webhookData?.data?.entity_id) return String(webhookData.data.entity_id)
if (webhookData?.id) return String(webhookData.id)
return undefined
}
export const stripHtmlTags = (html: string | undefined): string => {
if (!html) return ''
return html
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
export const getProductImageUrl = (images: BigCommerceProductImage[]): string => {
if (!images || images.length === 0) return ''
const thumbnailImage = images.find((img) => img.is_thumbnail)
return thumbnailImage ? thumbnailImage.url_standard : images[0]?.url_standard || ''
}
const handleProductCreateOrUpdate = async (
productId: string,
bigCommerceClient: BigCommerceClient,
botpressVanillaClient: Client,
tableName: string,
isCreated: boolean,
logger: bp.Logger
) => {
logger.forBot().info(`Fetching product details for ID: ${productId}`)
const productResponse = await bigCommerceClient.getProduct(productId.toString())
const product = productResponse.data
if (!product) return null
const categoriesResponse = await bigCommerceClient.getCategories()
const categoryById: Record<number, string> = {}
if (categoriesResponse && categoriesResponse.data) {
for (const category of categoriesResponse.data) {
categoryById[category.id] = category.name
}
}
const brandsResponse = await bigCommerceClient.getBrands()
const brandById: Record<number, string> = {}
if (brandsResponse && brandsResponse.data) {
for (const brand of brandsResponse.data) {
brandById[brand.id] = brand.name
}
}
const categoryNames =
product.categories?.map((categoryId: number) => categoryById[categoryId] || categoryId.toString()) || []
const categories = categoryNames.join(',')
const brandName = product.brand_id ? brandById[product.brand_id] || product.brand_id.toString() : ''
let imageUrl = ''
if (product.images && product.images.length > 0) {
imageUrl = getProductImageUrl(product.images)
}
const productRow = {
product_id: product.id,
name: product.name,
sku: product.sku,
price: product.price,
sale_price: product.sale_price,
retail_price: product.retail_price,
total_sold: product.total_sold || 0,
weight: product.weight,
type: product.type,
inventory_level: product.inventory_level,
inventory_tracking: product.inventory_tracking,
brand_name: brandName,
categories,
availability: product.availability,
condition: product.condition,
is_visible: product.is_visible,
sort_order: product.sort_order,
description: stripHtmlTags(product.description)?.substring(0, 1000) || '',
image_url: imageUrl,
url: product.custom_url?.url || '',
}
const { rows } = await botpressVanillaClient.findTableRows({
table: tableName,
filter: { product_id: product.id },
})
if (rows.length > 0 && rows[0]?.id) {
logger.forBot().info(`Updating existing product ID: ${productId}`)
await botpressVanillaClient.updateTableRows({
table: tableName,
rows: [{ id: rows[0].id, ...productRow }],
})
} else {
logger.forBot().info(`Creating new product ID: ${productId}`)
await botpressVanillaClient.createTableRows({
table: tableName,
rows: [productRow],
})
}
return {
success: true,
message: `Product ${productId} ${isCreated ? 'created' : 'updated'} successfully`,
}
}
const handleProductDelete = async (
productId: string,
botpressVanillaClient: Client,
tableName: string,
logger: bp.Logger
) => {
logger.forBot().info(`Deleting product ID: ${productId}`)
const productIdNumber = Number(productId)
const { rows } = await botpressVanillaClient.findTableRows({
table: tableName,
filter: { product_id: productIdNumber },
})
if (rows.length > 0 && rows[0]?.id) {
await botpressVanillaClient.deleteTableRows({
table: tableName,
ids: [rows[0].id],
})
return {
success: true,
message: `Product ${productId} deleted successfully`,
}
} else {
logger.forBot().warn(`Product ID ${productId} not found for deletion`)
return {
success: true,
message: `Product ${productId} not found for deletion`,
}
}
}
type BigCommerceConfig = {
storeHash: string
accessToken: string
clientId?: string
apiVersion?: string
}
const setupBigCommerceWebhooks = async (configuration: BigCommerceConfig, logger: bp.Logger, webhookId: string) => {
const webhookUrl = `https://webhook.botpress.cloud/${webhookId}`
logger.forBot().info('Setting up BigCommerce webhooks...')
try {
const bigCommerceClient = getBigCommerceClient(configuration)
const webhookResults = await bigCommerceClient.createProductWebhooks(webhookUrl)
logger.forBot().info('Webhooks created successfully!', webhookResults)
return { success: true }
} catch (webhookError) {
logger.forBot().error('Error creating webhooks:', webhookError)
return { success: false, error: webhookError }
}
}
const syncBigCommerceProducts = async (ctx: bp.Context, client: bp.Client, logger: bp.Logger) => {
logger.forBot().info('Syncing BigCommerce products...')
try {
const syncResult = await actions.syncProducts({
ctx,
client,
logger,
input: {
batchSize: 25,
productsPerPage: 100,
clearExisting: true,
},
type: 'syncProducts',
metadata: { setCost: (_cost: number) => {} },
})
logger.forBot().info('Product sync completed!')
return { success: true, result: syncResult }
} catch (syncError) {
logger.forBot().error('Error syncing products during initialization', syncError)
return { success: false, error: syncError }
}
}
const processWebhookEvent = async (
webhookType: WebhookType,
productId: string,
bigCommerceClient: BigCommerceClient,
botpressVanillaClient: Client,
tableName: string,
logger: bp.Logger,
ctx: bp.Context,
client: bp.Client
) => {
if (webhookType === 'created' || webhookType === 'updated') {
return await handleProductCreateOrUpdate(
productId.toString(),
bigCommerceClient,
botpressVanillaClient,
tableName,
webhookType === 'created',
logger
)
}
if (webhookType === 'deleted') {
return await handleProductDelete(productId.toString(), botpressVanillaClient, tableName, logger)
}
logger.forBot().warn(`Unrecognized event type: ${webhookType}, falling back to full sync`)
const result = await actions.syncProducts({
ctx,
client,
logger,
input: {},
type: 'syncProducts',
metadata: { setCost: (_cost: number) => {} },
})
if (result) {
result.message = 'Full sync performed (unrecognized event type)'
}
return result
}
const processWebhookByType = async (
webhookType: WebhookType,
productId: string,
bigCommerceClient: BigCommerceClient,
botpressVanillaClient: Client,
tableName: string,
logger: bp.Logger,
ctx: bp.Context,
client: bp.Client
) => {
try {
return await processWebhookEvent(
webhookType,
productId,
bigCommerceClient,
botpressVanillaClient,
tableName,
logger,
ctx,
client
)
} catch (error) {
logger.forBot().error(`Error processing ${webhookType} for product ${productId}:`, error)
throw error
}
}
export default new bp.Integration({
register: async ({ client, ctx, logger }) => {
try {
logger.forBot().info('Registering BigCommerce integration')
const botpressVanillaClient = getVanillaClient(client)
await botpressVanillaClient.getOrCreateTable({
table: PRODUCT_TABLE,
schema: PRODUCT_TABLE_SCHEMA,
})
const syncResult = await syncBigCommerceProducts(ctx, client, logger)
if (syncResult.success && ctx.webhookId) {
await setupBigCommerceWebhooks(ctx.configuration, logger, ctx.webhookId)
}
logger.forBot().info('BigCommerce integration registered successfully')
} catch (error) {
logger.forBot().error('Error registering BigCommerce integration', error)
}
},
unregister: async () => {},
actions,
channels: {},
handler: async ({ req, client, ctx, logger }) => {
if (req.method !== 'POST') {
return {
status: 405,
body: JSON.stringify({
success: false,
message: 'Method not allowed',
}),
}
}
try {
const isBCWebhook = isBigCommerceWebhook(req.headers)
const webhookData = typeof req.body === 'string' ? JSON.parse(req.body) : req.body
if (webhookData?.event === 'background-sync-triggered') {
logger.forBot().info('Processing internal background sync webhook')
const {
startPage,
totalPages,
tableName,
batchSize,
productsPerPage,
storeHash,
accessToken,
categoryById,
brandById,
} = webhookData.data
try {
const result = await executeBackgroundSync({
ctx: { configuration: { storeHash, accessToken } },
input: {
startPage,
totalPages,
tableName,
batchSize,
productsPerPage,
categoryById,
brandById,
},
logger,
client,
})
logger.forBot().info(`Background sync result: ${JSON.stringify(result)}`)
return {
status: 200,
body: JSON.stringify({
success: true,
message: 'Background processing completed successfully',
result,
}),
}
} catch (error) {
logger.forBot().error(`Error syncing products: ${error}`)
return {
status: 500,
body: JSON.stringify({
success: false,
message: `Background processing failed: ${error instanceof Error ? error.message : String(error)}`,
}),
}
}
}
if (!isBCWebhook) {
logger.forBot().warn('Rejecting request - not a BigCommerce webhook')
return {
status: 401,
body: JSON.stringify({
success: false,
message: 'Unauthorized: Request does not appear to be from BigCommerce',
}),
}
}
const botpressVanillaClient = getVanillaClient(client)
const tableName = PRODUCT_TABLE
const bigCommerceClient = getBigCommerceClient(ctx.configuration)
if (!webhookData) {
logger.forBot().warn('No webhook data found, unable to process')
return {
status: 400,
body: JSON.stringify({
success: false,
message: 'No webhook data found',
}),
}
}
const productId = extractProductId(webhookData)
let webhookType: WebhookType | undefined
if (webhookData.scope && typeof webhookData.scope === 'string' && webhookData.scope.includes('product')) {
webhookType = determineWebhookTypeFromScope(webhookData.scope)
} else if (webhookData.producer && webhookData.producer === 'product') {
webhookType = determineWebhookTypeFromProducer(webhookData)
}
if (!webhookType || !productId) {
logger.forBot().warn('Could not extract product ID or event type from webhook, falling back to full sync')
logger.forBot().info('Webhook info:', {
hasProductId: !!productId,
hasScope: !!webhookType,
})
const result = await actions.syncProducts({
ctx,
client,
logger,
input: {},
type: 'syncProducts',
metadata: { setCost: (_cost: number) => {} },
})
return {
status: 200,
body: JSON.stringify({
success: result.success,
message: 'Full sync performed (fallback)',
syncResult: result,
}),
}
}
logger.forBot().info(`Processing event: ${webhookType} for product ID: ${productId}`)
const result = await processWebhookByType(
webhookType,
productId.toString(),
bigCommerceClient,
botpressVanillaClient,
tableName,
logger,
ctx,
client
)
return {
status: 200,
body: JSON.stringify(result),
}
} catch (error) {
logger.forBot().error('Error handling webhook:', error)
return {
status: 500,
body: JSON.stringify({
success: false,
message: `Error handling webhook: ${error instanceof Error ? error.message : String(error)}`,
}),
}
}
},
})