chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+43
View File
@@ -0,0 +1,43 @@
# BigCommerce Sync Integration
Connect your BigCommerce store to Botpress to sync products from BigCommerce to your Botpress table.
## Installation and Configuration
### **[Walkthrough video for setting up the BigCommerce Integration](https://youtu.be/3Y_WlvMT8AA)**
### Prerequisites
1. A BigCommerce store with products
2. API credentials with appropriate permissions
### Getting BigCommerce API Credentials
1. Log in to your BigCommerce store and go to **Settings** in the left sidebar.
2. Search for **Store-level API accounts** and click on it.
3. Click **Create API Account**
4. Set the following permissions:
- Products: Read-only
- Information & Settings: Read-only
5. Save your credentials:
- Access Token
- Store Hash (Note: the store hash is within the URL of your BigCommerce store.
EX: https://store-{Store Hash}.mybigcommerce.com)
### Setting Up the Integration
1. Enter your BigCommerce Store Hash
2. Enter your API Access Token
3. Click **Save**
### Using the Integration
1. Go back to your Botpress studio and refresh the page.
2. You should see a new table in your Tables section labeled **bigcommerce_products_Table**.
Note: This table is automatically created when the integration is saved.
Note #2: The table will automatically update when you make changes to your BigCommerce store (product creation, updates, deletion).
### Actions
You can manually trigger the syncProducts action to sync products from BigCommerce to your Botpress table.
For example, you can connect this to a cron job to sync products every X amount of time.
+125
View File
@@ -0,0 +1,125 @@
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="100%" viewBox="0 0 697 696" enable-background="new 0 0 697 696" xml:space="preserve">
<path fill="#FFFFFF" opacity="1.000000" stroke="none"
d="
M0.999994,686.000000
C1.000000,457.736389 1.000000,229.472809 1.000000,1.000000
C230.026810,1.000000 459.053680,1.000000 688.461914,1.328607
C602.528809,88.475990 516.214355,175.294754 430.012817,262.000000
C442.323700,262.000000 455.405487,262.136108 468.483307,261.966248
C485.620880,261.743683 502.313080,264.193787 518.381287,270.154816
C534.152283,276.005554 548.052612,285.109833 558.454651,298.252594
C572.096680,315.488922 578.950867,335.104126 576.132080,357.681946
C574.146484,373.585754 567.780029,387.171814 557.408264,399.039124
C548.910706,408.761993 538.837341,416.479858 527.147095,422.143005
C525.595093,422.894897 524.241333,425.676666 524.246033,427.514679
C524.249817,429.015106 526.112305,431.226471 527.676392,431.897797
C545.410767,439.509125 558.828491,451.913757 567.878723,468.755402
C577.798340,487.214966 580.396545,506.988251 577.059143,527.697144
C573.678711,548.673035 564.665955,566.318665 548.345215,580.243469
C530.616943,595.369324 509.608246,602.494934 487.008057,602.749512
C423.366821,603.466675 359.712311,603.000793 296.063019,602.999023
C289.336884,602.998779 289.001251,602.662964 289.001068,595.934814
C288.999298,533.118652 289.000000,470.302460 289.000000,407.486298
C289.000000,405.735626 289.000000,403.984985 289.000000,401.094727
C287.080750,402.723328 285.955139,403.555847 284.971069,404.531219
C245.487656,443.665863 205.934189,482.730377 166.564911,521.979614
C112.875587,575.505249 59.333279,629.178406 5.688192,682.748474
C4.363798,684.070984 2.573167,684.926575 0.999994,686.000000
M472.254639,327.792725
C467.370056,326.861816 462.492065,325.167206 457.599854,325.125275
C428.181763,324.872986 398.760437,324.995483 369.340179,325.003998
C363.804779,325.005585 362.007294,326.798157 362.004181,332.316742
C361.993347,351.597809 362.147034,370.880981 361.872040,390.158112
C361.819031,393.873535 363.002106,395.808624 366.085907,397.536774
C366.384857,397.635651 366.683807,397.734497 367.911652,397.982452
C393.060852,397.988312 418.210449,398.080261 443.359100,397.963806
C455.998627,397.905273 469.108215,399.964844 480.103973,391.026215
C483.063660,388.620270 486.629578,386.697968 488.912689,383.770325
C496.205475,374.418762 497.793091,363.462372 494.821198,352.323212
C491.988281,341.704956 485.889679,333.105682 474.805054,328.306824
C474.203491,328.260101 473.601959,328.213379 472.254639,327.792725
M404.500000,540.000000
C424.487793,540.000000 444.484344,540.353088 464.459930,539.859314
C475.355957,539.589966 485.535645,536.338440 493.489655,528.058228
C499.364685,521.942322 501.977051,514.763306 503.261536,506.359528
C504.781708,496.413483 502.813751,487.602325 497.795563,479.426300
C489.784180,466.373566 476.713409,461.487640 462.461487,461.197571
C431.160278,460.560547 399.837189,461.010559 368.523010,460.988434
C363.992310,460.985229 361.974121,463.182678 361.982208,467.660980
C362.021637,489.480896 361.985809,511.300964 362.009796,533.120911
C362.015900,538.696289 363.344604,539.979858 369.021149,539.992432
C380.514038,540.017822 392.007050,540.000000 404.500000,540.000000
z"/>
<path fill="#34313F" opacity="1.000000" stroke="none"
d="
M0.999994,686.482971
C2.573167,684.926575 4.363798,684.070984 5.688192,682.748474
C59.333279,629.178406 112.875587,575.505249 166.564911,521.979614
C205.934189,482.730377 245.487656,443.665863 284.971069,404.531219
C285.955139,403.555847 287.080750,402.723328 289.000000,401.094727
C289.000000,403.984985 289.000000,405.735626 289.000000,407.486298
C289.000000,470.302460 288.999298,533.118652 289.001068,595.934814
C289.001251,602.662964 289.336884,602.998779 296.063019,602.999023
C359.712311,603.000793 423.366821,603.466675 487.008057,602.749512
C509.608246,602.494934 530.616943,595.369324 548.345215,580.243469
C564.665955,566.318665 573.678711,548.673035 577.059143,527.697144
C580.396545,506.988251 577.798340,487.214966 567.878723,468.755402
C558.828491,451.913757 545.410767,439.509125 527.676392,431.897797
C526.112305,431.226471 524.249817,429.015106 524.246033,427.514679
C524.241333,425.676666 525.595093,422.894897 527.147095,422.143005
C538.837341,416.479858 548.910706,408.761993 557.408264,399.039124
C567.780029,387.171814 574.146484,373.585754 576.132080,357.681946
C578.950867,335.104126 572.096680,315.488922 558.454651,298.252594
C548.052612,285.109833 534.152283,276.005554 518.381287,270.154816
C502.313080,264.193787 485.620880,261.743683 468.483307,261.966248
C455.405487,262.136108 442.323700,262.000000 430.012817,262.000000
C516.214355,175.294754 602.528809,88.475990 688.921631,1.328607
C691.999023,1.000000 694.997986,1.000000 697.997803,1.000000
C697.998535,232.922501 697.998535,464.845001 697.998535,696.883789
C465.666718,696.883789 233.333359,696.883789 1.000000,696.883789
C1.000000,693.630310 1.000000,690.298157 0.999994,686.482971
z"/>
<path fill="#34313F" opacity="1.000000" stroke="none"
d="
M404.000000,540.000000
C392.007050,540.000000 380.514038,540.017822 369.021149,539.992432
C363.344604,539.979858 362.015900,538.696289 362.009796,533.120911
C361.985809,511.300964 362.021637,489.480896 361.982208,467.660980
C361.974121,463.182678 363.992310,460.985229 368.523010,460.988434
C399.837189,461.010559 431.160278,460.560547 462.461487,461.197571
C476.713409,461.487640 489.784180,466.373566 497.795563,479.426300
C502.813751,487.602325 504.781708,496.413483 503.261536,506.359528
C501.977051,514.763306 499.364685,521.942322 493.489655,528.058228
C485.535645,536.338440 475.355957,539.589966 464.459930,539.859314
C444.484344,540.353088 424.487793,540.000000 404.000000,540.000000
z"/>
<path fill="#34313F" opacity="1.000000" stroke="none"
d="
M366.166626,397.013763
C363.002106,395.808624 361.819031,393.873535 361.872040,390.158112
C362.147034,370.880981 361.993347,351.597809 362.004181,332.316742
C362.007294,326.798157 363.804779,325.005585 369.340179,325.003998
C398.760437,324.995483 428.181763,324.872986 457.599854,325.125275
C462.492065,325.167206 467.370056,326.861816 472.669617,328.266235
C473.737732,328.788971 474.390900,328.838165 475.044037,328.887390
C485.889679,333.105682 491.988281,341.704956 494.821198,352.323212
C497.793091,363.462372 496.205475,374.418762 488.912689,383.770325
C486.629578,386.697968 483.063660,388.620270 480.103973,391.026215
C469.108215,399.964844 455.998627,397.905273 443.359100,397.963806
C418.210449,398.080261 393.060852,397.988312 367.355316,397.731567
C366.635345,397.261200 366.424561,397.105560 366.166626,397.013763
z"/>
<path fill="#34313F" opacity="1.000000" stroke="none"
d="
M474.924561,328.597107
C474.390900,328.838165 473.737732,328.788971 473.042480,328.453186
C473.601959,328.213379 474.203491,328.260101 474.924561,328.597107
z"/>
<path fill="#34313F" opacity="1.000000" stroke="none"
d="
M366.126282,397.275269
C366.424561,397.105560 366.635345,397.261200 366.890869,397.657043
C366.683807,397.734497 366.384857,397.635651 366.126282,397.275269
z"/>
</svg>

After

Width:  |  Height:  |  Size: 7.2 KiB

@@ -0,0 +1,20 @@
import { IntegrationDefinition } from '@botpress/sdk'
import { configuration, states, actions } from './src/definitions/index'
export default new IntegrationDefinition({
name: 'bigcommerce',
title: 'BigCommerce',
version: '3.2.4',
readme: 'hub.md',
icon: 'icon.svg',
description: 'Sync products from BigCommerce to Botpress',
configuration,
actions: {
syncProducts: actions.syncProducts,
},
states,
attributes: {
category: 'CRM & Sales',
repo: 'botpress',
},
})
@@ -0,0 +1,19 @@
{
"name": "@botpresshub/bigcommerce",
"scripts": {
"check:type": "tsc --noEmit",
"check:bplint": "bp lint",
"build": "bp add -y && bp build",
"test": "vitest --run"
},
"private": true,
"dependencies": {
"@botpress/client": "workspace:*",
"@botpress/sdk": "workspace:*",
"axios": "^1.7.2"
},
"devDependencies": {
"@botpress/cli": "workspace:*",
"@botpress/sdk": "workspace:*"
}
}
@@ -0,0 +1,6 @@
import syncProducts from './sync-products'
import * as bp from '.botpress'
export default {
syncProducts,
} satisfies bp.IntegrationProps['actions']
@@ -0,0 +1,427 @@
import { Client } from '@botpress/client'
import { getBigCommerceClient, BigCommerceClient } from '../client'
import { getProductImageUrl, BigCommerceProductImage, stripHtmlTags } from '../index'
import { PRODUCT_TABLE_SCHEMA, PRODUCTS_TABLE_NAME as PRODUCT_TABLE } from '../schemas/products'
import * as bp from '.botpress'
type BigCommerceProduct = {
id: number
name: string
sku?: string
price?: number
sale_price?: number
retail_price?: number
weight?: number
type?: string
inventory_level?: number
inventory_tracking?: string
brand_id?: number
categories?: number[]
availability?: string
condition?: string
is_visible?: boolean
sort_order?: number
description?: string
images?: Array<BigCommerceProductImage>
custom_url?: { url: string }
total_sold?: number
}
type SyncConfig = {
storeHash: string
accessToken: string
tableName: string
batchSize?: number
productsPerPage?: number
}
const DEFAULT_BATCH_SIZE = 50
const DEFAULT_PRODUCTS_PER_PAGE = 250
function transformProductToTableRow(
product: BigCommerceProduct,
categoryById: Record<number, string>,
brandById: Record<number, string>
) {
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() : ''
const imageUrl = product.images && product.images.length > 0 ? getProductImageUrl(product.images) : ''
return {
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 || '',
}
}
async function saveProductsToTable(
client: Client,
tableName: string,
products: BigCommerceProduct[],
categoryById: Record<number, string>,
brandById: Record<number, string>,
batchSize: number = DEFAULT_BATCH_SIZE
) {
try {
const tableRows = products.map((product) => transformProductToTableRow(product, categoryById, brandById))
const totalRows = tableRows.length
let processedRows = 0
while (processedRows < totalRows) {
const batch = tableRows.slice(processedRows, processedRows + batchSize)
await client.createTableRows({
table: tableName,
rows: batch,
})
processedRows += batch.length
}
return totalRows
} catch (error) {
console.error('Failed to save products to table:', error)
throw error
}
}
async function loadCategoriesAndBrands(bigCommerceClient: BigCommerceClient) {
const categoryById: Record<number, string> = {}
const brandById: Record<number, string> = {}
try {
const categoriesResponse = await bigCommerceClient.getCategories()
if (categoriesResponse && categoriesResponse.data) {
categoriesResponse.data.forEach((category: { id: number; name: string }) => {
categoryById[category.id] = category.name
})
}
const brandsResponse = await bigCommerceClient.getBrands()
if (brandsResponse && brandsResponse.data) {
brandsResponse.data.forEach((brand: { id: number; name: string }) => {
brandById[brand.id] = brand.name
})
}
} catch (error) {
console.error('Error loading categories/brands:', error)
}
return { categoryById, brandById }
}
async function processPages({
bigCommerceClient,
botpressClient,
tableName,
categoryById,
brandById,
startPage,
totalPages,
batchSize,
productsPerPage,
logger,
}: {
bigCommerceClient: BigCommerceClient
botpressClient: Client
tableName: string
categoryById: Record<number, string>
brandById: Record<number, string>
startPage: number
totalPages: number
batchSize: number
productsPerPage: number
logger: bp.Logger
}): Promise<{ processedItems: number; lastProcessedPage: number; errors: string[] }> {
const startTime = Date.now()
let processedItems = 0
let lastProcessedPage = startPage - 1
const errors: string[] = []
const totalPagesToProcess = totalPages - startPage + 1
logger.forBot().info(`Processing pages from ${startPage} to ${totalPages} (${totalPagesToProcess} pages total)`)
for (let page = startPage; page <= totalPages; page++) {
const pagesProcessed = page - startPage
const progress = Math.round((pagesProcessed / totalPagesToProcess) * 100)
try {
logger.forBot().info(`Processing page ${page}/${totalPages} (${progress}% complete)...`)
const pageResult = await bigCommerceClient.getProducts({ page, limit: productsPerPage })
if (!pageResult.data || pageResult.data.length === 0) {
logger.forBot().warn(`No products found on page ${page}`)
continue
}
// Save products to table
await saveProductsToTable(botpressClient, tableName, pageResult.data, categoryById, brandById, batchSize)
processedItems += pageResult.data.length
lastProcessedPage = page
logger.forBot().info(`Completed page ${page}: ${pageResult.data.length} products`)
await new Promise((resolve) => setTimeout(resolve, 50))
} catch (error) {
const errorMessage = `Error processing page ${page}: ${error instanceof Error ? error.message : 'Unknown error'}`
logger.forBot().error(errorMessage)
errors.push(errorMessage)
continue
}
}
const totalElapsed = Date.now() - startTime
const pagesCompleted = lastProcessedPage - startPage + 1
const finalProgress = Math.round((pagesCompleted / totalPagesToProcess) * 100)
logger
.forBot()
.info(
`Processing completed: ${pagesCompleted}/${totalPagesToProcess} pages (${finalProgress}%) ` +
`in ${Math.round(totalElapsed / 1000)}s. Processed ${processedItems} products.`
)
return { processedItems, lastProcessedPage, errors }
}
function initializeClients(ctx: { storeHash: string; accessToken: string }, client: bp.Client) {
const bigCommerceClient = getBigCommerceClient(ctx)
const getVanillaClient = (client: bp.Client): Client => client._inner
const botpressVanillaClient = getVanillaClient(client)
return { bigCommerceClient, botpressVanillaClient }
}
export async function executeBackgroundSync({
ctx,
input,
logger,
client,
}: {
ctx: { configuration: { storeHash: string; accessToken: string } }
input: {
startPage: number
totalPages: number
tableName: string
batchSize: number
productsPerPage: number
categoryById: Record<number, string>
brandById: Record<number, string>
}
logger: bp.Logger
client: bp.Client
}) {
const { startPage, totalPages, tableName, batchSize, productsPerPage, categoryById, brandById } = input
const { bigCommerceClient, botpressVanillaClient } = initializeClients(ctx.configuration, client)
logger.forBot().info('Starting background processing...')
const result = await processPages({
bigCommerceClient,
botpressClient: botpressVanillaClient,
tableName,
categoryById,
brandById,
startPage,
totalPages,
batchSize,
productsPerPage,
logger,
})
return {
success: true,
processedItems: result.processedItems,
lastProcessedPage: result.lastProcessedPage,
totalPages,
errors: result.errors,
finalProgress: Math.round(((result.lastProcessedPage - startPage + 1) / (totalPages - startPage + 1)) * 100),
totalElapsed: Math.round((Date.now() - Date.now()) / 1000),
}
}
const syncProducts: bp.IntegrationProps['actions']['syncProducts'] = async (props) => {
const { client, logger } = props
const ctx = props.ctx.configuration
const { bigCommerceClient, botpressVanillaClient } = initializeClients(ctx, client)
const config: SyncConfig = {
storeHash: ctx.storeHash,
accessToken: ctx.accessToken,
tableName: PRODUCT_TABLE,
batchSize: DEFAULT_BATCH_SIZE,
productsPerPage: DEFAULT_PRODUCTS_PER_PAGE,
}
try {
logger.forBot().info('Starting BigCommerce product sync')
await botpressVanillaClient.getOrCreateTable({
table: config.tableName,
schema: PRODUCT_TABLE_SCHEMA,
})
try {
logger.forBot().info('Clearing existing products table...')
await botpressVanillaClient.deleteTable({
table: config.tableName,
})
await botpressVanillaClient.getOrCreateTable({
table: config.tableName,
schema: PRODUCT_TABLE_SCHEMA,
})
} catch (error) {
logger.forBot().warn('Error clearing products table:', error)
}
// Step 1: Load categories and brands
logger.forBot().info('Loading categories and brands...')
const { categoryById, brandById } = await loadCategoriesAndBrands(bigCommerceClient)
logger
.forBot()
.info(`Loaded ${Object.keys(categoryById).length} categories and ${Object.keys(brandById).length} brands`)
// Step 2: Get first page synchronously
logger.forBot().info('Fetching first page synchronously...')
const firstPageResponse = await bigCommerceClient.getProducts({ page: 1, limit: config.productsPerPage! })
const totalPages: number = firstPageResponse.meta?.pagination?.total_pages ?? 1
const firstPageCount = firstPageResponse.data?.length ?? 0
if (firstPageCount === 0) {
logger.forBot().warn('No products found in BigCommerce store')
return {
success: true,
message: 'No products found in BigCommerce store',
productsCount: 0,
firstPageProcessed: 0,
totalPages: 0,
backgroundProcessing: false,
}
}
// Step 3: Process first page using unified function
logger.forBot().info(`Processing first page (${firstPageCount} products)...`)
await processPages({
bigCommerceClient,
botpressClient: botpressVanillaClient,
tableName: config.tableName,
categoryById,
brandById,
startPage: 1,
totalPages: 1,
batchSize: DEFAULT_BATCH_SIZE,
productsPerPage: DEFAULT_PRODUCTS_PER_PAGE,
logger,
})
logger.forBot().info(`First page processed. Total pages: ${totalPages}, First page items: ${firstPageCount}`)
// Step 4: If there are more pages, send webhook to trigger background processing
if (totalPages > 1) {
logger.forBot().info('First page processed. Sending webhook to trigger background processing...')
const webhookUrl = `https://webhook.botpress.cloud/${props.ctx.webhookId}`
const payload = {
event: 'background-sync-triggered',
data: {
startPage: 2,
totalPages,
tableName: config.tableName,
batchSize: DEFAULT_BATCH_SIZE,
productsPerPage: DEFAULT_PRODUCTS_PER_PAGE,
storeHash: config.storeHash,
accessToken: config.accessToken,
categoryById,
brandById,
},
}
try {
fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
}).catch((error) => {
logger.forBot().error('Failed to send background processing webhook:', error)
})
logger.forBot().info('Background processing webhook sent successfully')
return {
success: true,
firstPageProcessed: firstPageCount,
totalPages,
productsCount: firstPageCount,
backgroundProcessing: true,
lastProcessedPage: 1,
backgroundErrors: [],
message: `First page processed synchronously. Background processing webhook sent for remaining ${totalPages - 1} pages.`,
}
} catch (error) {
logger.forBot().error('Failed to send background processing webhook:', error)
return {
success: false,
firstPageProcessed: firstPageCount,
totalPages,
productsCount: firstPageCount,
backgroundProcessing: false,
lastProcessedPage: 1,
backgroundErrors: [
`Failed to send background processing webhook: ${error instanceof Error ? error.message : String(error)}`,
],
message: 'First page processed but failed to send background processing webhook.',
}
}
} else {
return {
success: true,
firstPageProcessed: firstPageCount,
totalPages: 1,
productsCount: firstPageCount,
backgroundProcessing: false,
message: 'All products synced synchronously (single page).',
}
}
} catch (error) {
logger.forBot().error('Sync failed:', error)
return {
success: false,
message: `Error syncing BigCommerce products: ${error instanceof Error ? error.message : String(error)}`,
productsCount: 0,
firstPageProcessed: 0,
totalPages: 0,
backgroundProcessing: false,
}
}
}
export default syncProducts
+142
View File
@@ -0,0 +1,142 @@
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'
import * as bp from '.botpress'
export type ProductQueryParams = {
id?: number | number[]
name?: string
sku?: string
price?: number
page?: number
limit?: number
include?: string
brand_id?: number
categories?: number | number[]
keyword?: string
status?: string
include_fields?: string
exclude_fields?: string
sort?: string
direction?: 'asc' | 'desc'
}
export class BigCommerceClient {
private _client: AxiosInstance
private _baseUrl: string
public constructor(private _config: bp.configuration.Configuration) {
this._baseUrl = `https://api.bigcommerce.com/stores/${_config.storeHash}`
this._client = axios.create({
headers: {
'X-Auth-Token': _config.accessToken,
'Content-Type': 'application/json',
Accept: 'application/json',
},
})
}
public async getProducts(params?: ProductQueryParams) {
try {
const updatedParams = {
...params,
include: params?.include ? `${params.include},images` : 'images',
}
const response = await this._client.get(`${this._baseUrl}/v3/catalog/products`, { params: updatedParams })
return response.data
} catch (error) {
throw this._handleError(error)
}
}
public async getProduct(productId: string) {
try {
const response = await this._client.get(`${this._baseUrl}/v3/catalog/products/${productId}`, {
params: { include: 'images' },
})
return response.data
} catch (error) {
throw this._handleError(error)
}
}
public async getCategories() {
try {
const response = await this._client.get(`${this._baseUrl}/v3/catalog/categories`)
return response.data
} catch (error) {
throw this._handleError(error)
}
}
public async getBrands() {
try {
const response = await this._client.get(`${this._baseUrl}/v3/catalog/brands`)
return response.data
} catch (error) {
throw this._handleError(error)
}
}
public async makeRequest(config: AxiosRequestConfig) {
try {
const url = config.url?.startsWith('http') ? config.url : `${this._baseUrl}${config.url}`
const response = await this._client.request({
...config,
url,
})
return {
status: response.status,
data: response.data,
}
} catch (error) {
throw this._handleError(error)
}
}
public async createWebhook(scope: string, destination: string) {
try {
const response = await this._client.post(`${this._baseUrl}/v3/hooks`, {
scope,
destination,
is_active: true,
headers: {},
})
return response.data
} catch (error) {
throw this._handleError(error)
}
}
public async createProductWebhooks(destination: string) {
if (!destination) {
throw new Error('Webhook destination URL is required')
}
const webhookEvents = ['store/product/updated', 'store/product/created', 'store/product/deleted']
const results = []
for (const event of webhookEvents) {
try {
const result = await this.createWebhook(event, destination)
results.push({ event, success: true, data: result })
} catch (error) {
results.push({ event, success: false, error: error instanceof Error ? error.message : String(error) })
}
}
return results
}
private _handleError(error: unknown) {
if (axios.isAxiosError(error)) {
const message = error.response?.data?.message || error.message
return new Error(`BigCommerce API Error: ${message}`)
}
return error instanceof Error ? error : new Error(String(error))
}
}
export const getBigCommerceClient = (config: bp.configuration.Configuration): BigCommerceClient =>
new BigCommerceClient(config)
@@ -0,0 +1,32 @@
import { z, IntegrationDefinitionProps } from '@botpress/sdk'
export const configuration = {
schema: z.object({
storeHash: z.string().min(1).title('Store Hash').describe('Your BigCommerce store hash (e.g., abc123)'),
accessToken: z.string().min(1).title('Access Token').describe('BigCommerce API Access Token'),
}),
} satisfies IntegrationDefinitionProps['configuration']
export const states = {} satisfies IntegrationDefinitionProps['states']
export const actions = {
syncProducts: {
title: 'Sync Products',
description: 'Get all products from BigCommerce and sync them to a Botpress table with background processing',
input: {
schema: z.object({}),
},
output: {
schema: z.object({
success: z.boolean().describe('Whether the sync was successful'),
message: z.string().describe('Status message'),
productsCount: z.number().describe('Total number of products processed'),
firstPageProcessed: z.number().describe('Number of products processed from first page'),
totalPages: z.number().describe('Total number of pages'),
backgroundProcessing: z.boolean().describe('Whether background processing was used'),
lastProcessedPage: z.number().optional().describe('Last page processed during background processing'),
backgroundErrors: z.array(z.string()).optional().describe('Errors encountered during background processing'),
}),
},
},
} satisfies IntegrationDefinitionProps['actions']
+504
View File
@@ -0,0 +1,504 @@
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)}`,
}),
}
}
},
})
@@ -0,0 +1,28 @@
export const PRODUCT_TABLE_SCHEMA = {
type: 'object',
properties: {
product_id: { type: 'number', 'x-zui': { searchable: false } },
name: { type: 'string', 'x-zui': { searchable: true } },
sku: { type: 'string', 'x-zui': { searchable: false } },
price: { type: 'number', 'x-zui': { searchable: false } },
sale_price: { type: 'number', 'x-zui': { searchable: false } },
retail_price: { type: 'number', 'x-zui': { searchable: false } },
total_sold: { type: 'number', 'x-zui': { searchable: false } },
weight: { type: 'number', 'x-zui': { searchable: false } },
type: { type: 'string', 'x-zui': { searchable: false } },
inventory_level: { type: 'number', 'x-zui': { searchable: false } },
inventory_tracking: { type: 'string', 'x-zui': { searchable: false } },
brand_name: { type: 'string', 'x-zui': { searchable: true } },
categories: { type: 'string', 'x-zui': { searchable: true } },
availability: { type: 'string', 'x-zui': { searchable: false } },
condition: { type: 'string', 'x-zui': { searchable: false } },
is_visible: { type: 'boolean', 'x-zui': { searchable: false } },
sort_order: { type: 'number', 'x-zui': { searchable: false } },
description: { type: 'string', 'x-zui': { searchable: true } },
image_url: { type: 'string', 'x-zui': { searchable: false } },
url: { type: 'string', 'x-zui': { searchable: false } },
},
required: ['product_id', 'name'],
}
export const PRODUCTS_TABLE_NAME = 'bigcommerce_products_Table'
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist"
},
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
}
@@ -0,0 +1,2 @@
import config from '../../vitest.config'
export default config