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): 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 = {} if (categoriesResponse && categoriesResponse.data) { for (const category of categoriesResponse.data) { categoryById[category.id] = category.name } } const brandsResponse = await bigCommerceClient.getBrands() const brandById: Record = {} 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)}`, }), } } }, })