diff --git a/integrations/bigcommerce-sync/integration.definition.ts b/integrations/bigcommerce-sync/integration.definition.ts index 7f742ce31de..974dca5ca5b 100644 --- a/integrations/bigcommerce-sync/integration.definition.ts +++ b/integrations/bigcommerce-sync/integration.definition.ts @@ -4,7 +4,7 @@ import { configuration, states, actions } from './src/definitions/index' export default new IntegrationDefinition({ name: 'bigcommerce', title: 'BigCommerce', - version: '3.1.1', + version: '3.2.0', readme: 'hub.md', icon: 'icon.svg', description: 'Sync products from BigCommerce to Botpress', diff --git a/integrations/bigcommerce-sync/src/actions/sync-products.ts b/integrations/bigcommerce-sync/src/actions/sync-products.ts index ed503387964..d38e7b67793 100644 --- a/integrations/bigcommerce-sync/src/actions/sync-products.ts +++ b/integrations/bigcommerce-sync/src/actions/sync-products.ts @@ -27,86 +27,91 @@ type BigCommerceProduct = { total_sold?: number } -function processProductsPage( - products: BigCommerceProduct[] | undefined, - allProducts: BigCommerceProduct[], - currentPage: number, - PRODUCTS_PER_PAGE: number -): { hasMoreProducts: boolean; nextPage: number } { - if (!products || products.length === 0) { - return { hasMoreProducts: false, nextPage: currentPage } - } - - allProducts.push(...products) - - if (products.length < PRODUCTS_PER_PAGE) { - return { hasMoreProducts: false, nextPage: currentPage } - } - - return { hasMoreProducts: true, nextPage: currentPage + 1 } +type SyncConfig = { + storeHash: string + accessToken: string + tableName: string + batchSize?: number + productsPerPage?: number } -async function fetchAllProducts(bigCommerceClient: BigCommerceClient) { - const allProducts: BigCommerceProduct[] = [] - let currentPage = 1 - let hasMoreProducts = true - const PRODUCTS_PER_PAGE = 250 - - while (hasMoreProducts) { - const response = await bigCommerceClient.getProducts({ - page: currentPage, - limit: PRODUCTS_PER_PAGE, - }) - - const { hasMoreProducts: shouldContinue, nextPage } = processProductsPage( - response.data, - allProducts, - currentPage, - PRODUCTS_PER_PAGE - ) - - hasMoreProducts = shouldContinue - currentPage = nextPage +const DEFAULT_BATCH_SIZE = 50 +const DEFAULT_PRODUCTS_PER_PAGE = 250 + +function transformProductToTableRow( + product: BigCommerceProduct, + categoryById: Record, + brandById: Record +) { + 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 || '', } - - return allProducts } -const syncProducts: bp.IntegrationProps['actions']['syncProducts'] = async (props) => { - const { client, logger } = props - const ctx = props.ctx.configuration - - // this client is necessary for table operations - const getVanillaClient = (client: bp.Client): Client => client._inner - const botpressVanillaClient = getVanillaClient(client) - - const bigCommerceClient = getBigCommerceClient(ctx) - +async function saveProductsToTable( + client: Client, + tableName: string, + products: BigCommerceProduct[], + categoryById: Record, + brandById: Record, + batchSize: number = DEFAULT_BATCH_SIZE +) { try { - const tableName = PRODUCT_TABLE - const tableSchema = PRODUCT_TABLE_SCHEMA + const tableRows = products.map((product) => transformProductToTableRow(product, categoryById, brandById)) - await botpressVanillaClient.getOrCreateTable({ - table: tableName, - schema: tableSchema, - }) + const totalRows = tableRows.length + let processedRows = 0 - const allProducts = await fetchAllProducts(bigCommerceClient) + while (processedRows < totalRows) { + const batch = tableRows.slice(processedRows, processedRows + batchSize) - if (allProducts.length === 0) { - logger.forBot().warn('No products found in BigCommerce store') - return { - success: true, - message: 'No products found in BigCommerce store', - productsCount: 0, - } + await client.createTableRows({ + table: tableName, + rows: batch, + }) + + processedRows += batch.length } - logger.forBot().info(`Total products fetched: ${allProducts.length}`) + return totalRows + } catch (error) { + console.error('Failed to save products to table:', error) + throw error + } +} - const categoriesResponse = await bigCommerceClient.getCategories() - const categoryById: Record = {} +async function loadCategoriesAndBrands(bigCommerceClient: BigCommerceClient) { + const categoryById: Record = {} + const brandById: Record = {} + try { + const categoriesResponse = await bigCommerceClient.getCategories() if (categoriesResponse && categoriesResponse.data) { categoriesResponse.data.forEach((category: { id: number; name: string }) => { categoryById[category.id] = category.name @@ -114,86 +119,307 @@ const syncProducts: bp.IntegrationProps['actions']['syncProducts'] = async (prop } const brandsResponse = await bigCommerceClient.getBrands() - const brandById: Record = {} - 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) + } - const tableRows = allProducts.map((product: BigCommerceProduct) => { - const categoryNames = - product.categories?.map((categoryId: number) => categoryById[categoryId] || categoryId.toString()) || [] + return { categoryById, brandById } +} - 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) : '' +async function processPages({ + bigCommerceClient, + botpressClient, + tableName, + categoryById, + brandById, + startPage, + totalPages, + batchSize, + productsPerPage, + logger, +}: { + bigCommerceClient: BigCommerceClient + botpressClient: Client + tableName: string + categoryById: Record + brandById: Record + 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) - 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 || '', + 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 + brandById: Record + } + 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('Dropping existing products table...') + logger.forBot().info('Clearing existing products table...') await botpressVanillaClient.deleteTable({ - table: tableName, + table: config.tableName, }) await botpressVanillaClient.getOrCreateTable({ - table: tableName, - schema: tableSchema, + table: config.tableName, + schema: PRODUCT_TABLE_SCHEMA, }) } catch (error) { - logger.forBot().warn('Error dropping products table', error) + logger.forBot().warn('Error clearing products table:', error) } - const BATCH_SIZE = 50 - const totalRows = tableRows.length - let processedRows = 0 + // 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`) - while (processedRows < totalRows) { - const batch = tableRows.slice(processedRows, processedRows + BATCH_SIZE) + // Step 2: Get first page synchronously + logger.forBot().info('Fetching first page synchronously...') - await botpressVanillaClient.createTableRows({ - table: tableName, - rows: batch, - }) + 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 - processedRows += batch.length + 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, + } } - return { - success: true, - message: `Successfully synced ${allProducts.length} products from BigCommerce`, - productsCount: allProducts.length, + // 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('Error syncing BigCommerce products', 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, } } } diff --git a/integrations/bigcommerce-sync/src/definitions/index.ts b/integrations/bigcommerce-sync/src/definitions/index.ts index ba6bd5562e5..9735b119466 100644 --- a/integrations/bigcommerce-sync/src/definitions/index.ts +++ b/integrations/bigcommerce-sync/src/definitions/index.ts @@ -20,7 +20,7 @@ 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', + description: 'Get all products from BigCommerce and sync them to a Botpress table with background processing', input: { schema: z.object({}), }, @@ -28,7 +28,12 @@ export const actions = { schema: z.object({ success: z.boolean().describe('Whether the sync was successful'), message: z.string().describe('Status message'), - productsCount: z.number().describe('Number of products synced'), + 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'), }), }, }, diff --git a/integrations/bigcommerce-sync/src/index.ts b/integrations/bigcommerce-sync/src/index.ts index 72ea86ec53a..8258ef5a92d 100644 --- a/integrations/bigcommerce-sync/src/index.ts +++ b/integrations/bigcommerce-sync/src/index.ts @@ -1,5 +1,6 @@ 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' @@ -232,7 +233,11 @@ const syncBigCommerceProducts = async (ctx: bp.Context, client: bp.Client, logge ctx, client, logger, - input: {}, + input: { + batchSize: 25, + productsPerPage: 100, + clearExisting: true, + }, type: 'syncProducts', metadata: { setCost: (_cost: number) => {} }, }) @@ -352,6 +357,61 @@ export default new bp.Integration({ 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 { @@ -363,8 +423,6 @@ export default new bp.Integration({ } } - const webhookData = typeof req.body === 'string' ? JSON.parse(req.body) : req.body - const botpressVanillaClient = getVanillaClient(client) const tableName = PRODUCT_TABLE const bigCommerceClient = getBigCommerceClient(ctx.configuration) diff --git a/integrations/bigcommerce-sync/src/schemas/products.ts b/integrations/bigcommerce-sync/src/schemas/products.ts index 89d9e7f9c81..c55f0d6e4af 100644 --- a/integrations/bigcommerce-sync/src/schemas/products.ts +++ b/integrations/bigcommerce-sync/src/schemas/products.ts @@ -1,26 +1,26 @@ export const PRODUCT_TABLE_SCHEMA = { type: 'object', properties: { - product_id: { type: 'number' }, + product_id: { type: 'number', 'x-zui': { searchable: false } }, name: { type: 'string', 'x-zui': { searchable: true } }, - sku: { type: 'string' }, - price: { type: 'number' }, - sale_price: { type: 'number' }, - retail_price: { type: 'number' }, - total_sold: { type: 'number' }, - weight: { type: 'number' }, - type: { type: 'string' }, - inventory_level: { type: 'number' }, - inventory_tracking: { type: 'string' }, + 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' }, - condition: { type: 'string' }, - is_visible: { type: 'boolean' }, - sort_order: { type: 'number' }, + 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' }, - url: { type: 'string' }, + image_url: { type: 'string', 'x-zui': { searchable: false } }, + url: { type: 'string', 'x-zui': { searchable: false } }, }, required: ['product_id', 'name'], } diff --git a/integrations/zendesk/integration.definition.ts b/integrations/zendesk/integration.definition.ts index 52166fc217f..7e9f3503afe 100644 --- a/integrations/zendesk/integration.definition.ts +++ b/integrations/zendesk/integration.definition.ts @@ -7,7 +7,7 @@ import { actions, events, configuration, channels, states, user } from './src/de export default new sdk.IntegrationDefinition({ name: 'zendesk', title: 'Zendesk', - version: '2.8.2', + version: '2.8.3', icon: 'icon.svg', description: 'Optimize your support workflow. Trigger workflows from ticket updates as well as manage tickets, access conversations, and engage with customers.', diff --git a/integrations/zendesk/src/events/hitl-ticket-filter.ts b/integrations/zendesk/src/events/hitl-ticket-filter.ts index 0815e6e3e0a..cd5c22bb91e 100644 --- a/integrations/zendesk/src/events/hitl-ticket-filter.ts +++ b/integrations/zendesk/src/events/hitl-ticket-filter.ts @@ -13,6 +13,13 @@ export const retrieveHitlConversation = async ({ ctx: bp.Context logger: bp.Logger }) => { + if (!zendeskTrigger.externalId?.length) { + logger.forBot().debug('No external ID associated with the Zendesk ticket. Ignoring the ticket...', { + zendeskTicketId: zendeskTrigger.ticketId, + }) + return + } + if (!ctx.configuration.ignoreNonHitlTickets) { const { conversation } = await client.getOrCreateConversation({ channel: 'hitl', @@ -22,13 +29,6 @@ export const retrieveHitlConversation = async ({ return conversation } - if (!zendeskTrigger.externalId?.length) { - logger.forBot().debug('No external ID associated with the Zendesk ticket. Ignoring the ticket...', { - zendeskTicketId: zendeskTrigger.ticketId, - }) - return - } - try { const { conversation } = await client.getConversation({ id: zendeskTrigger.externalId }) diff --git a/integrations/zendesk/src/events/message-received.ts b/integrations/zendesk/src/events/message-received.ts index 89f5fb7b161..52dae42a48e 100644 --- a/integrations/zendesk/src/events/message-received.ts +++ b/integrations/zendesk/src/events/message-received.ts @@ -17,6 +17,15 @@ export const executeMessageReceived = async ({ ctx: bp.Context logger: bp.Logger }) => { + const isSystemNotification = zendeskTrigger.currentUser.id === '-1' + + if (isSystemNotification) { + logger.forBot().debug('Ignoring system notification message from Zendesk', { + zendeskTrigger, + }) + return + } + const conversation = await retrieveHitlConversation({ zendeskTrigger, client,