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,29 @@
import * as sdk from '@botpress/sdk'
const _baseItem = (itemType: 'file' = 'file') =>
sdk.z.object({
id: sdk.z
.string()
.describe(
`The ${itemType}'s ID. This could be a unique identifier from the external service, or a relative or absolute path, so long as it's unique.`
),
name: sdk.z
.string()
.describe(
`The ${itemType}'s name. This will be displayed in the Botpress UI and be used as the ${itemType}'s name on Files API."`
),
type: sdk.z.union([sdk.z.literal('file'), sdk.z.literal('folder')]).describe('The entity type'),
})
export const FILE = _baseItem().extend({
type: sdk.z.literal('file'),
sizeInBytes: sdk.z.number().optional().describe('The file size in bytes, if available'),
contentHash: sdk.z.string().optional().describe('A content hash provided by the external service for deduplication'),
})
export const FILE_WITH_PATH = FILE.extend({
absolutePath: sdk.z.string().describe('The absolute path of the file'),
})
export type File = sdk.z.infer<typeof FILE>
export type FileWithPath = sdk.z.infer<typeof FILE_WITH_PATH>
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+1
View File
@@ -0,0 +1 @@
# Knowledge Connector Plugin
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="241" height="241" fill="none"><path fill="purple" d="M34 0A34 34 0 0 0 0 34v173a34 34 0 0 0 34 34h173a34 34 0 0 0 34-34V34a34 34 0 0 0-34-34Zm51.8 33.6h62a8.8 8.7 0 0 1 3.2.6 8.8 8.7 0 0 1 .2.1 8.8 8.7 0 0 1 2.8 1.9l44.2 43.4a8.8 8.7 0 0 1 1.9 2.8 8.8 8.7 0 0 1 0 .2 8.8 8.7 0 0 1 .7 3.2v95.7c0 14.3-12 26.1-26.5 26.1h-48.7a8.8 8.7 0 0 1-8.8-8.7 8.8 8.7 0 0 1 8.8-8.7h48.7c5 0 8.8-3.8 8.8-8.7v-87h-26.5c-9.7 0-17.7-7.9-17.7-17.4v-26H85.8c-5 0-8.9 3.7-8.9 8.6v69.6a8.8 8.7 0 0 1-8.8 8.7 8.8 8.7 0 0 1-8.9-8.7V59.7c0-14.3 12-26.1 26.6-26.1zm70.8 29.7v13.8h14zm-79.7 83.4a8.8 8.7 0 0 1 6.3 2.6l26.5 26a8.8 8.7 0 0 1 2.6 6.2 8.8 8.7 0 0 1-2.6 6.2l-26.5 26a8.8 8.7 0 0 1-12.5 0 8.8 8.7 0 0 1 0-12.2L82 190.2H41.6a8.8 8.7 0 0 1-8.9-8.7 8.8 8.7 0 0 1 8.9-8.7H82l-11.4-11.2a8.8 8.7 0 0 1 0-12.3 8.8 8.7 0 0 1 6.2-2.6z"/></svg>

After

Width:  |  Height:  |  Size: 886 B

+20
View File
@@ -0,0 +1,20 @@
{
"name": "@botpresshub/knowledge-connector",
"scripts": {
"check:type": "tsc --noEmit",
"build": "bp add -y && bp build",
"test": "vitest --run"
},
"private": true,
"dependencies": {
"@botpress/client": "workspace:*",
"@botpress/sdk": "workspace:*"
},
"devDependencies": {
"@botpress/cli": "workspace:*",
"@botpress/sdk": "workspace:*"
},
"bpDependencies": {
"files-readonly": "../../interfaces/files-readonly"
}
}
@@ -0,0 +1,278 @@
import * as sdk from '@botpress/sdk'
import filesReadonly from './bp_modules/files-readonly'
export default new sdk.PluginDefinition({
name: 'knowledge-connector',
version: '1.0.0',
title: 'Knowledge Connector',
description: 'Synchronize files from external services to Botpress',
icon: 'icon.svg',
readme: 'hub.md',
attributes: {
// This plugin should be invisible to users:
...sdk.WELL_KNOWN_ATTRIBUTES.HIDDEN_IN_STUDIO,
},
configuration: {
schema: sdk.z.object({
scheduledSyncIntervalHours: sdk.z
.number()
.default(0)
.title('Scheduled Sync Interval (hours)')
.describe(
'The interval in hours between scheduled re-syncs. Set to 0 to disable. The plugin checks periodically and triggers a sync if enough time has elapsed since the last one.'
),
}),
},
events: {
scheduledSync: {
schema: sdk.z.object({}),
},
},
recurringEvents: {
triggerScheduledSync: {
type: 'scheduledSync',
payload: {},
schedule: {
cron: '* * * * *', // runs every minute; handler gates execution based on scheduledSyncIntervalHours
},
},
},
states: {
folderSyncSettings: {
type: 'bot',
schema: sdk.z.object({
settings: sdk.z.record(
sdk.z.string(),
sdk.z.record(
sdk.z.string(),
sdk.z.object({
syncNewFiles: sdk.z
.boolean()
.default(false)
.title('Sync New Files')
.describe(
'When enabled, automatically sync new files added to this folder to Botpress. Files will be synchronized in real-time as they are added to the folder. Real-time sync is considered enabled if at least one folder has syncNewFiles set to true.'
),
path: sdk.z
.string()
.optional()
.title('Folder Path')
.describe(
'The absolute path of the folder in the integration tree structure. Used to identify and locate the folder for synchronization.'
),
integrationInstanceAlias: sdk.z
.string()
.optional()
.title('Integration Instance Alias')
.describe('The alias of the integration instance (e.g. "dropbox") used for this folder sync.'),
integrationDefinitionName: sdk.z
.string()
.optional()
.title('Integration Definition Name')
.describe('The name of the integration definition (e.g. "dropbox") used for this folder sync.'),
transferFileToBotpressAlias: sdk.z
.string()
.optional()
.title('Transfer File Action Alias')
.describe('The alias of the transferFileToBotpress action for this integration.'),
})
)
),
}),
},
lastScheduledSync: {
type: 'bot',
schema: sdk.z.object({
lastSyncAt: sdk.z
.string()
.optional()
.title('Last Sync At')
.describe('ISO timestamp of the last scheduled sync execution.'),
}),
},
},
actions: {
listSynchronizationOperations: {
title: 'List current synchronization operations',
description: 'Get the list of ongoing synchronization operations',
input: {
schema: sdk.z.object({}),
},
output: {
schema: sdk.z.object({
isBusy: sdk.z
.boolean()
.title('Is Busy')
.describe('Indicates whether at least one synchronization operation is in progress'),
synchronizationsOperations: sdk.z
.array(
sdk.z
.object({
integrationInstanceAlias: sdk.z
.string()
.title('Integration Instance Alias')
.describe('The alias of the integration instance being used for synchronization'),
startedAt: sdk.z
.string()
.title('Started At')
.describe('The timestamp when the synchronization process started'),
syncJobId: sdk.z.string().title('Sync Job ID').describe('The unique ID of the synchronization job'),
})
.title('Synchronization Status')
.describe('Details about the individual synchronization operation')
)
.title('Synchronizations In Progress')
.describe('List of ongoing synchronization operations'),
}),
},
},
checkSynchronizationStatus: {
title: 'Check synchronization progress',
description: 'Get the current status of a synchronization operation',
input: {
schema: sdk.z.object({
syncJobId: sdk.z.string().title('Sync Job ID').describe('The unique ID of the synchronization job to check'),
}),
},
output: {
schema: sdk.z.object({
currentStatus: sdk.z
.enum(['inProgress', 'completed', 'failed'])
.title('Current Status')
.describe('The current status of the synchronization operation'),
failureReason: sdk.z
.string()
.optional()
.title('Failure Reason')
.describe('The reason for the failure, if the synchronization has failed'),
integrationInstanceAlias: sdk.z
.string()
.title('Integration Instance Alias')
.describe('The alias of the integration instance being used for synchronization'),
startedAt: sdk.z
.string()
.title('Started At')
.describe('The timestamp when the synchronization process started'),
endedAt: sdk.z
.string()
.optional()
.title('Ended At')
.describe(
'The timestamp when the synchronization process ended. Present only if the sync is completed or failed.'
),
totalFiles: sdk.z.number().title('Total Files').describe('The total number of files to synchronize'),
processedFiles: sdk.z
.number()
.title('Processed Files')
.describe('The number of files that have been processed so far'),
skippedFiles: sdk.z
.number()
.title('Skipped Files')
.describe('The number of files that were skipped during synchronization'),
failedFiles: sdk.z.number().title('Failed Files').describe('The number of files that failed to synchronize'),
}),
},
},
syncFilesToBotpress: {
title: 'Sync files to Botpress',
description: 'Start synchronization of files from the external service to Botpress',
input: {
schema: sdk.z.object({
includeFiles: sdk.z
.array(
sdk.z.object({
id: sdk.z.string().title('File ID').describe('The unique identifier of the file to include.'),
name: sdk.z.string().title('File Name').describe('The name of the file to include.'),
absolutePath: sdk.z
.string()
.placeholder('Example: /path/to/file')
.describe('The absolute path of the file to include.'),
sizeInBytes: sdk.z
.number()
.title('Size in Bytes')
.describe('The size of the file in bytes.')
.optional(),
contentHash: sdk.z
.string()
.title('Content Hash')
.describe('A content hash provided by the external service for deduplication.')
.optional(),
})
)
.title('Files to include')
.describe('Files to include during synchronization.'),
addToKbId: sdk.z
.string()
.placeholder('Example: kb-2f0a7ea639')
.optional()
.title('Knowledge Base ID')
.describe(
'The ID of the knowledge base to add the files to. Note that files added to knowledge bases will count towards both the Vector DB Storage quota and the File Storage quota of the workspace.'
),
integrationInstanceAlias: sdk.z
.string()
.title('files-readonly Integration Alias')
.describe(
'The alias of the integration to use for synchronization. Must implement the files-readonly interface.'
),
integrationDefinitionName: sdk.z
.string()
.title('files-readonly Integration Definition Name')
.describe(
'The name of the integration to use for synchronization. Must implement the files-readonly interface.'
),
transferFileToBotpressAlias: sdk.z
.string()
.title('Integration Interface Action Alias')
.describe(
'The alias of the transferFileToBotpress of the files-readonly interface within the integration.'
),
}),
},
output: {
schema: sdk.z.object({
syncJobId: sdk.z.string().title('Sync Job ID').describe('The unique ID of the new sync job.'),
}),
},
},
},
interfaces: {
'files-readonly': sdk.version.allWithinMajorOf(filesReadonly),
},
workflows: {
processQueue: {
title: 'Process file sync queue',
description: 'Process the file sync queue and synchronize files to Botpress',
input: {
schema: sdk.z.object({
jobFileId: sdk.z.string().title('Job File').describe("The ID of the job's queue file"),
transferFileToBotpressAlias: sdk.z
.string()
.title('Transfer File to Botpress Action Alias')
.describe('The alias of the action used to transfer files to Botpress'),
}),
},
output: {
schema: sdk.z.object({}),
},
tags: {
syncJobId: {
title: 'Sync job ID',
description: 'The unique ID of the sync job',
},
syncInitiatedAt: {
title: 'Created at',
description: 'The date and time when the sync job was created',
},
integrationDefinitionName: {
title: 'Integration Definition Name',
description: 'The name of the integration used for synchronization',
},
integrationInstanceAlias: {
title: 'Integration Instance Alias',
description: 'The alias of the integration instance used for synchronization',
},
},
},
},
})
@@ -0,0 +1,142 @@
import * as sdk from '@botpress/sdk'
import { WORKFLOW_ACTIVE_STATUSES } from '../consts'
import { QUEUE_ITEM_SCHEMA, type Workflow } from '../types'
import * as utils from '../utils'
import * as bp from '.botpress'
type WorkflowStatusResult = {
currentStatus: 'inProgress' | 'completed' | 'failed'
failureReason?: string
endedAt?: string
}
type FileStats = {
totalFiles: number
processedFiles: number
skippedFiles: number
failedFiles: number
}
export const callAction: bp.PluginHandlers['actionHandlers']['checkSynchronizationStatus'] = async (props) => {
const { syncJobId } = props.input
props.logger.debug(`Checking synchronization status for sync job with ID "${syncJobId}"`)
const allWorkflows = await props.workflows.processQueue.listInstances({ tags: { syncJobId } }).take(1)
if (allWorkflows.length === 0) {
props.logger.warn(`No workflow found for sync job with ID "${syncJobId}"`)
return _createNotFoundResponse(syncJobId)
}
const workflow = allWorkflows[0]!
const integrationInstanceAlias = workflow.tags.integrationInstanceAlias || ''
const startedAt = workflow.tags.syncInitiatedAt || workflow.createdAt
const statusResult = _getWorkflowStatusResult(workflow)
let fileStats: FileStats = {
totalFiles: 0,
processedFiles: 0,
skippedFiles: 0,
failedFiles: 0,
}
if (workflow.input.jobFileId) {
try {
const { file: jobFile } = await props.client.getFile({ id: workflow.input.jobFileId })
const res = await fetch(jobFile.url, { signal: AbortSignal.timeout(30000) })
if (!res.ok) {
throw new Error(`Failed to download sync queue file: HTTP ${res.status} ${res.statusText}`)
}
const jobFileContent = await res.text()
fileStats = _computeFileStats(jobFileContent, props.logger)
} catch (error) {
props.logger.error(`Failed to retrieve sync queue file: ${error}. File stats will be unavailable.`)
fileStats = {
totalFiles: -1,
processedFiles: -1,
skippedFiles: -1,
failedFiles: -1,
}
}
}
return {
...statusResult,
integrationInstanceAlias,
startedAt,
...fileStats,
}
}
const _getWorkflowStatusResult = (workflow: Workflow): WorkflowStatusResult => {
if (WORKFLOW_ACTIVE_STATUSES.includes(workflow.status as (typeof WORKFLOW_ACTIVE_STATUSES)[number])) {
return { currentStatus: 'inProgress' }
}
if (workflow.status === 'completed') {
return { currentStatus: 'completed', endedAt: workflow.updatedAt }
}
if (workflow.status === 'failed') {
return {
currentStatus: 'failed',
failureReason: workflow.failureReason || 'Synchronization failed',
endedAt: workflow.updatedAt,
}
}
return {
currentStatus: 'failed',
failureReason: `Workflow ended with status: ${workflow.status}`,
endedAt: workflow.updatedAt,
}
}
const _computeFileStats = (jobFileContent: string, logger: sdk.BotLogger): FileStats => {
const stats: FileStats = {
totalFiles: 0,
processedFiles: 0,
skippedFiles: 0,
failedFiles: 0,
}
const syncQueueGenerator = utils.jsonl.parseJsonLines(jobFileContent, QUEUE_ITEM_SCHEMA)
for (const item of syncQueueGenerator) {
if ('error' in item) {
logger.error('Error while parsing line in job file. This line will be ignored.', item)
continue
}
stats.totalFiles++
switch (item.value.status) {
case 'newly-synced':
stats.processedFiles++
break
case 'already-synced':
stats.processedFiles++
stats.skippedFiles++
break
case 'errored':
stats.failedFiles++
break
}
}
return stats
}
const _createNotFoundResponse = (syncJobId: string) => ({
currentStatus: 'failed' as const,
failureReason: `No synchronization job found with ID "${syncJobId}"`,
integrationInstanceAlias: '',
startedAt: '',
endedAt: undefined,
totalFiles: 0,
processedFiles: 0,
skippedFiles: 0,
failedFiles: 0,
})
@@ -0,0 +1,3 @@
export * as syncFilesToBotpress from './sync-files-to-botpress'
export * as listSynchronizationOperations from './list-synchronization-operations'
export * as checkSynchronizationStatus from './check-synchronization-status'
@@ -0,0 +1,28 @@
import { WORKFLOW_ACTIVE_STATUSES } from '../consts'
import type { Workflow } from '../types'
import * as bp from '.botpress'
type SyncOperation = {
integrationInstanceAlias: string
startedAt: string
syncJobId: string
}
export const callAction: bp.PluginHandlers['actionHandlers']['listSynchronizationOperations'] = async (props) => {
const activeWorkflows = await props.workflows.processQueue
.listInstances({ statuses: [...WORKFLOW_ACTIVE_STATUSES] })
.take(100)
const synchronizationsOperations = activeWorkflows.map(
(workflow: Workflow): SyncOperation => ({
integrationInstanceAlias: workflow.tags.integrationInstanceAlias || '',
startedAt: workflow.tags.syncInitiatedAt || workflow.createdAt,
syncJobId: workflow.tags.syncJobId || '',
})
)
return {
isBusy: synchronizationsOperations.length > 0,
synchronizationsOperations,
}
}
@@ -0,0 +1,109 @@
import * as sdk from '@botpress/sdk'
import { WORKFLOW_ACTIVE_STATUSES } from '../consts'
import * as SyncQueue from '../sync-queue'
import { randomUUID } from '../utils/crypto'
import { getAliasedName } from '../utils/get-aliased-name'
import * as bp from '.botpress'
export const callAction: bp.PluginHandlers['actionHandlers']['syncFilesToBotpress'] = async (props) => {
const { includeFiles, integrationInstanceAlias, integrationDefinitionName, transferFileToBotpressAlias } = props.input
await _assertSyncNotAlreadyInProgress(props)
_assertAtLeastOneFileToSync(includeFiles)
const syncJobId = await randomUUID()
const fileKey = _generateFileKey({
integrationInstanceAlias,
pluginInstanceAlias: props.alias,
syncJobId,
})
props.logger.info(
`Starting synchronization workflow for integration instance with alias "${integrationInstanceAlias}"`,
{ syncJobId, fileKey, filesCount: includeFiles.length }
)
const syncInitiatedAt = new Date().toISOString()
let jobFileId: string | undefined
try {
jobFileId = await SyncQueue.jobFileManager.updateSyncQueue(
props,
fileKey,
includeFiles.map((file) => {
const contentHash = 'contentHash' in file && typeof file.contentHash === 'string' ? file.contentHash : undefined
return {
id: file.id,
name: file.name,
absolutePath: file.absolutePath,
type: 'file',
status: 'pending',
sizeInBytes: file.sizeInBytes,
contentHash,
addToKbId: props.input.addToKbId,
}
}),
{
syncJobId,
integrationName: integrationDefinitionName,
integrationInstanceAlias,
syncInitiatedAt,
}
)
await props.workflows.processQueue.startNewInstance({
input: {
jobFileId,
transferFileToBotpressAlias,
},
tags: {
integrationDefinitionName,
integrationInstanceAlias,
syncJobId,
syncInitiatedAt,
},
})
} catch (error) {
if (jobFileId) {
await props.client.deleteFile({ id: jobFileId }).catch(() => {})
}
throw error
}
return { syncJobId }
}
const _assertSyncNotAlreadyInProgress = async (
props: Extract<bp.ActionHandlerProps, { type?: 'syncFilesToBotpress' }>
) => {
const { integrationInstanceAlias } = props.input
const workflowsInProgress = await props.workflows.processQueue
.listInstances({
statuses: [...WORKFLOW_ACTIVE_STATUSES],
tags: {
integrationInstanceAlias,
},
})
.take(1)
if (workflowsInProgress.length > 0) {
throw new sdk.RuntimeError(
`A synchronization is already in progress for integration instance with alias "${integrationInstanceAlias}". Please wait for it to complete before starting a new one.`
)
}
}
const _assertAtLeastOneFileToSync = (includeFiles: unknown[]) => {
if (includeFiles.length === 0) {
throw new sdk.RuntimeError('No files specified for synchronization.')
}
}
const _generateFileKey = (props: {
integrationInstanceAlias: string
pluginInstanceAlias: string
syncJobId: string
}) =>
`${getAliasedName('knowledge-connector', props.pluginInstanceAlias)}:${props.integrationInstanceAlias}:/${props.syncJobId}.jsonl`
@@ -0,0 +1,3 @@
export const MAX_BATCH_SIZE_BYTES = 104857600 // 100MB
export const WORKFLOW_ACTIVE_STATUSES = ['in_progress', 'listening', 'paused', 'pending'] as const
@@ -0,0 +1,4 @@
export * as onWorkflowStart from './workflow-started'
export * as onWorkflowContinue from './workflow-continued'
export * as onWorkflowTimeout from './workflow-timed-out'
export * as onEvent from './on-event'
@@ -0,0 +1,100 @@
import { extractIntegrationAlias } from '../../utils/extract-integration-alias'
import { handleEvent as handleFileCreated } from './file-created'
import { handleEvent as handleFileDeleted } from './file-deleted'
import { handleEvent as handleFileUpdated } from './file-updated'
import { handleEvent as handleFolderDeletedRecursive } from './folder-deleted-recursive'
import * as bp from '.botpress'
const _getErrorMessage = (reason: unknown): string => (reason instanceof Error ? reason.message : String(reason))
export const handleEvent: bp.EventHandlers['files-readonly:aggregateFileChanges'] = async (props) => {
const modifiedItems = props.event.payload.modifiedItems
const integrationAlias = extractIntegrationAlias(props.event.type, props.logger)
if (!integrationAlias) {
return
}
// Process deletes first to avoid conflicts with upserts
const deletePromises = modifiedItems.deleted.map((deletedItem) => {
if (deletedItem.type === 'file') {
return handleFileDeleted({
...props,
event: {
...props.event,
type: `${integrationAlias}:fileDeleted` as 'files-readonly:fileDeleted',
payload: { file: deletedItem },
},
})
} else {
return handleFolderDeletedRecursive({
...props,
event: {
...props.event,
type: `${integrationAlias}:folderDeletedRecursive` as 'files-readonly:folderDeletedRecursive',
payload: { folder: deletedItem },
},
})
}
})
const deleteResults = await Promise.allSettled(deletePromises)
deleteResults.forEach((result, index) => {
if (result.status !== 'rejected') {
return
}
const deletedItem = modifiedItems.deleted[index]
const itemPath = deletedItem?.absolutePath ?? deletedItem?.id ?? 'unknown'
props.logger.error(
`Failed to process deleted item ${itemPath} during aggregateFileChanges: ${_getErrorMessage(result.reason)}`
)
})
const upsertPromises: Promise<void>[] = []
const createdFiles = modifiedItems.created.filter(
(item): item is (typeof modifiedItems.created)[number] & { type: 'file' } => item.type === 'file'
)
const updatedFiles = modifiedItems.updated.filter(
(item): item is (typeof modifiedItems.updated)[number] & { type: 'file' } => item.type === 'file'
)
for (const createdItem of createdFiles) {
upsertPromises.push(
handleFileCreated({
...props,
event: {
...props.event,
type: `${integrationAlias}:fileCreated` as 'files-readonly:fileCreated',
payload: { file: createdItem },
},
})
)
}
for (const updatedItem of updatedFiles) {
upsertPromises.push(
handleFileUpdated({
...props,
event: {
...props.event,
type: `${integrationAlias}:fileUpdated` as 'files-readonly:fileUpdated',
payload: { file: updatedItem },
},
})
)
}
const upsertResults = await Promise.allSettled(upsertPromises)
upsertResults.forEach((result, index) => {
if (result.status !== 'rejected') {
return
}
// TODO: Add retry/requeue handling for failed aggregate upserts.
const sourceItem = index < createdFiles.length ? createdFiles[index] : updatedFiles[index - createdFiles.length]
const itemPath = sourceItem?.absolutePath ?? sourceItem?.id ?? 'unknown'
props.logger.error(
`Failed to process upserted file ${itemPath} during aggregateFileChanges: ${_getErrorMessage(result.reason)}`
)
})
}
@@ -0,0 +1,6 @@
import { handleFileUpsert } from './file-upserted'
import * as bp from '.botpress'
export const handleEvent: bp.EventHandlers['files-readonly:fileCreated'] = async (props) => {
await handleFileUpsert(props)
}
@@ -0,0 +1,26 @@
import { extractIntegrationAlias } from '../../utils/extract-integration-alias'
import * as bp from '.botpress'
export const handleEvent: bp.EventHandlers['files-readonly:fileDeleted'] = async (props) => {
const deletedFile = props.event.payload.file
const integrationAlias = extractIntegrationAlias(props.event.type, props.logger)
if (!integrationAlias) {
return
}
try {
const { files } = await props.client.listFiles({
tags: {
externalId: deletedFile.id,
integrationAlias,
},
})
for (const filesApiFile of files) {
await props.client.deleteFile({ id: filesApiFile.id })
}
} catch (error) {
props.logger.error(`Error deleting file ${deletedFile.absolutePath}:`, error)
}
}
@@ -0,0 +1,6 @@
import { handleFileUpsert } from './file-upserted'
import * as bp from '.botpress'
export const handleEvent: bp.EventHandlers['files-readonly:fileUpdated'] = async (props) => {
await handleFileUpsert(props)
}
@@ -0,0 +1,64 @@
import * as SyncQueue from '../../sync-queue'
import { createIntegrationTransferHandler } from '../../utils/create-integration-transfer-handler'
import { extractIntegrationAlias } from '../../utils/extract-integration-alias'
import { findFolderByPath } from '../../utils/find-folder-by-path'
import type * as bp from '.botpress'
type FileUpsertProps = bp.EventHandlerProps & {
event: {
payload: {
file: { id: string; name: string; absolutePath: string; type: 'file'; sizeInBytes?: number; contentHash?: string }
}
}
}
export const handleFileUpsert = async (props: FileUpsertProps) => {
const file = props.event.payload.file
const integrationAlias = extractIntegrationAlias(props.event.type, props.logger)
if (!integrationAlias) {
return
}
let settings
try {
settings = await props.states.bot.folderSyncSettings.get(props.ctx.botId)
} catch {
return
}
if (!settings?.settings) {
return
}
const folderMatch = findFolderByPath(settings.settings, file.absolutePath, integrationAlias)
if (!folderMatch || !folderMatch.syncNewFiles) {
return
}
const { kbId, integrationDefinitionName, transferFileToBotpressAlias } = folderMatch
const result = await SyncQueue.fileProcessor.processQueueFile({
fileRepository: props.client,
fileToSync: {
...file,
status: 'pending',
addToKbId: kbId,
},
integration: createIntegrationTransferHandler({
integrationName: integrationDefinitionName ?? integrationAlias,
integrationAlias,
client: props.client,
transferFileToBotpressAlias,
shouldIndex: kbId !== undefined,
}),
logger: props.logger,
})
if (result.status === 'errored') {
props.logger.error(`File ${file.absolutePath} failed to synchronize: ${result.errorMessage}`)
} else {
props.logger.info(`File ${file.absolutePath} has been synchronized`)
}
}
@@ -0,0 +1,45 @@
import { extractIntegrationAlias } from '../../utils/extract-integration-alias'
import * as bp from '.botpress'
export const handleEvent: bp.EventHandlers['files-readonly:folderDeletedRecursive'] = async (props) => {
const deletedFolder = props.event.payload.folder
const integrationAlias = extractIntegrationAlias(props.event.type, props.logger)
if (!integrationAlias) {
return
}
try {
if (!deletedFolder.absolutePath) {
props.logger.warn(`Skipping folder deletion: folder has no absolutePath (id: ${deletedFolder.id})`)
return
}
const folderPath = deletedFolder.absolutePath?.endsWith('/')
? deletedFolder.absolutePath
: `${deletedFolder.absolutePath}/`
const filesToDelete: Array<{ id: string }> = []
let nextToken: string | undefined
do {
const response = await props.client.listFiles({
tags: { integrationAlias },
nextToken,
})
for (const file of response.files) {
const externalPath = file.tags?.['externalPath']
if (externalPath && externalPath.startsWith(folderPath)) {
filesToDelete.push({ id: file.id })
}
}
nextToken = response.meta?.nextToken
} while (nextToken)
await Promise.allSettled(filesToDelete.map((file) => props.client.deleteFile({ id: file.id })))
} catch (error) {
props.logger.error(`Error deleting files in folder ${deletedFolder.absolutePath}:`, error)
}
}
@@ -0,0 +1,5 @@
export * as fileCreated from './file-created'
export * as fileDeleted from './file-deleted'
export * as fileUpdated from './file-updated'
export * as folderDeletedRecursive from './folder-deleted-recursive'
export * as aggregateFileChanges from './aggregate-file-changes'
@@ -0,0 +1,269 @@
import { WORKFLOW_ACTIVE_STATUSES } from '../../consts'
import * as SyncQueue from '../../sync-queue'
import { randomUUID } from '../../utils/crypto'
import { enumerateFilesInFolder } from '../../utils/enumerate-files'
import { getAliasedName } from '../../utils/get-aliased-name'
import type * as bp from '.botpress'
type ScheduledSyncProps = bp.EventHandlerProps
type FolderConfig = {
syncNewFiles?: boolean
path?: string
integrationInstanceAlias?: string
integrationDefinitionName?: string
transferFileToBotpressAlias?: string
}
type FolderSyncSettings = Record<string, Record<string, FolderConfig>>
type IntegrationGroup = {
integrationInstanceAlias: string
integrationDefinitionName: string
transferFileToBotpressAlias: string
folders: Array<{ folderId: string; path: string; kbId: string }>
}
export const handleEvent = async (props: ScheduledSyncProps) => {
const { logger, ctx } = props
if ((props.configuration?.scheduledSyncIntervalHours ?? 0) <= 0) {
logger.debug('Scheduled sync disabled (interval is 0)')
return
}
const shouldSync = await _hasEnoughTimeElapsed(props, props.configuration?.scheduledSyncIntervalHours ?? 0)
if (!shouldSync) {
logger.debug('Scheduled sync skipped: not enough time elapsed since last sync')
return
}
const settings = await _getFolderSyncSettings(props)
if (!settings) {
logger.debug('Scheduled sync skipped: no folder sync settings found')
return
}
const integrationGroups = _groupFoldersByIntegration(settings, logger)
if (integrationGroups.length === 0) {
logger.debug('Scheduled sync skipped: no folders with integration info configured')
return
}
logger.info(`Scheduled sync starting for ${integrationGroups.length} integration(s)`)
// Set lastSyncAt immediately to prevent concurrent cron invocations from
// starting a duplicate sync while this one is still running.
const syncStartedAt = new Date().toISOString()
await props.states.bot.lastScheduledSync.set(ctx.botId, {
lastSyncAt: syncStartedAt,
})
let hasSucceeded = false
try {
for (const group of integrationGroups) {
try {
await _syncIntegrationGroup(props, group)
hasSucceeded = true
} catch (error) {
logger.error(
`Scheduled sync failed for integration "${group.integrationInstanceAlias}": ${error instanceof Error ? error.message : String(error)}`
)
}
}
} finally {
if (!hasSucceeded) {
// Reset lastSyncAt so the next cron trigger retries immediately
await props.states.bot.lastScheduledSync.set(ctx.botId, {
lastSyncAt: undefined,
})
logger.warn('Scheduled sync: all integration groups failed, will retry on next trigger')
}
}
}
const _hasEnoughTimeElapsed = async (props: ScheduledSyncProps, intervalHours: number): Promise<boolean> => {
try {
const { lastSyncAt } = await props.states.bot.lastScheduledSync.get(props.ctx.botId)
if (!lastSyncAt) {
return true
}
const elapsed = Date.now() - new Date(lastSyncAt).getTime()
return elapsed >= intervalHours * 60 * 60 * 1000
} catch {
return true
}
}
const _getFolderSyncSettings = async (props: ScheduledSyncProps) => {
try {
const state = await props.states.bot.folderSyncSettings.get(props.ctx.botId)
return state?.settings
} catch {
return null
}
}
const _groupFoldersByIntegration = (
settings: FolderSyncSettings,
logger?: { warn: (...args: unknown[]) => void }
): IntegrationGroup[] => {
const groupMap = new Map<string, IntegrationGroup>()
for (const [kbId, folders] of Object.entries(settings)) {
for (const [folderId, config] of Object.entries(folders)) {
if (!config.integrationInstanceAlias || !config.integrationDefinitionName) {
continue
}
const alias = config.integrationInstanceAlias
let group = groupMap.get(alias)
if (!group) {
group = {
integrationInstanceAlias: alias,
integrationDefinitionName: config.integrationDefinitionName,
transferFileToBotpressAlias: config.transferFileToBotpressAlias ?? 'filesReadonlyTransferFileToBotpress',
folders: [],
}
groupMap.set(alias, group)
}
const configuredAlias = config.transferFileToBotpressAlias ?? 'filesReadonlyTransferFileToBotpress'
if (configuredAlias !== group.transferFileToBotpressAlias) {
logger?.warn(
`Integration "${alias}" has conflicting transferFileToBotpressAlias values: ` +
`"${group.transferFileToBotpressAlias}" (used) vs "${configuredAlias}" (ignored, from KB "${kbId}")`
)
}
group.folders.push({
folderId,
path: config.path ?? '/',
kbId,
})
}
}
return Array.from(groupMap.values())
}
const _syncIntegrationGroup = async (props: ScheduledSyncProps, group: IntegrationGroup) => {
const { logger } = props
const isBusy = await _isSyncInProgress(props, group.integrationInstanceAlias)
if (isBusy) {
logger.info(`Scheduled sync skipped for "${group.integrationInstanceAlias}": sync already in progress`)
return
}
const allFiles: Array<{
id: string
name: string
absolutePath: string
sizeInBytes?: number
contentHash?: string
kbId: string
}> = []
for (const folder of group.folders) {
try {
const files = await enumerateFilesInFolder({
listItemsInFolder: (input) =>
props.client
.callAction({
type: `${group.integrationInstanceAlias}:filesReadonlyListItemsInFolder`,
input,
})
.then((r) => r.output),
folderId: folder.folderId,
folderPath: folder.path,
logger,
})
for (const file of files) {
allFiles.push({ ...file, kbId: folder.kbId })
}
} catch (error) {
logger.error(
`Failed to enumerate files in folder "${folder.path}" (${folder.folderId}): ${error instanceof Error ? error.message : String(error)}`
)
}
}
if (allFiles.length === 0) {
logger.info(`Scheduled sync: no files found for integration "${group.integrationInstanceAlias}"`)
return
}
// Group files by kbId to create separate sync jobs per knowledge base
const filesByKb = new Map<string, typeof allFiles>()
for (const file of allFiles) {
const existing = filesByKb.get(file.kbId) ?? []
existing.push(file)
filesByKb.set(file.kbId, existing)
}
for (const [kbId, files] of filesByKb) {
const syncJobId = await randomUUID()
const fileKey = `${getAliasedName('knowledge-connector', props.alias)}:${group.integrationInstanceAlias}:/${syncJobId}.jsonl`
const syncInitiatedAt = new Date().toISOString()
logger.info(
`Scheduled sync: starting job for "${group.integrationInstanceAlias}" KB "${kbId}" with ${files.length} files`,
{ syncJobId }
)
let jobFileId: string | undefined
try {
jobFileId = await SyncQueue.jobFileManager.updateSyncQueue(
props,
fileKey,
files.map((file) => ({
id: file.id,
name: file.name,
absolutePath: file.absolutePath,
type: 'file' as const,
status: 'pending' as const,
sizeInBytes: file.sizeInBytes,
contentHash: file.contentHash,
addToKbId: kbId,
})),
{
syncJobId,
integrationName: group.integrationDefinitionName,
integrationInstanceAlias: group.integrationInstanceAlias,
syncInitiatedAt,
}
)
await props.workflows.processQueue.startNewInstance({
input: {
jobFileId,
transferFileToBotpressAlias: group.transferFileToBotpressAlias,
},
tags: {
integrationDefinitionName: group.integrationDefinitionName,
integrationInstanceAlias: group.integrationInstanceAlias,
syncJobId,
syncInitiatedAt,
},
})
} catch (error) {
if (jobFileId) {
await props.client.deleteFile({ id: jobFileId }).catch(() => {})
}
throw error
}
}
}
const _isSyncInProgress = async (props: ScheduledSyncProps, integrationInstanceAlias: string): Promise<boolean> => {
const workflowsInProgress = await props.workflows.processQueue
.listInstances({
statuses: [...WORKFLOW_ACTIVE_STATUSES],
tags: { integrationInstanceAlias },
})
.take(1)
return workflowsInProgress.length > 0
}
@@ -0,0 +1 @@
export * as processQueue from './process-queue'
@@ -0,0 +1,59 @@
import * as SyncQueue from '../../sync-queue'
import { createIntegrationTransferHandler } from '../../utils/create-integration-transfer-handler'
import * as bp from '.botpress'
export const handleEvent: bp.WorkflowHandlers['processQueue'] = async (props) => {
const logger = props.logger.withWorkflowId(props.workflow.id)
try {
const { syncQueue, key } = await SyncQueue.jobFileManager.getSyncQueue(props)
const hasKbTarget = syncQueue.some((item) => item.addToKbId !== undefined)
if (
!props.workflow.tags.integrationInstanceAlias ||
!props.workflow.tags.syncJobId ||
!props.workflow.tags.integrationDefinitionName ||
!props.workflow.tags.syncInitiatedAt
) {
throw new Error(
'Missing required workflow tags: integrationInstanceAlias, integrationDefinitionName, syncJobId, and syncInitiatedAt'
)
}
await props.workflow.acknowledgeStartOfProcessing()
const processResult = await SyncQueue.queueProcessor.processQueue({
logger,
syncQueue,
fileRepository: props.client,
integration: createIntegrationTransferHandler({
integrationName: props.workflow.tags.integrationDefinitionName,
integrationAlias: props.workflow.tags.integrationInstanceAlias,
client: props.client,
transferFileToBotpressAlias: props.workflow.input.transferFileToBotpressAlias,
shouldIndex: hasKbTarget,
}),
updateSyncQueue: (params) =>
SyncQueue.jobFileManager.updateSyncQueue(props, key, params.syncQueue, {
syncJobId: props.workflow.tags.syncJobId!,
integrationName: props.workflow.tags.integrationDefinitionName!,
integrationInstanceAlias: props.workflow.tags.integrationInstanceAlias!,
syncInitiatedAt: props.workflow.tags.syncInitiatedAt!,
}),
})
if (processResult.finished === 'batch') {
logger.info('Batch sync success. Continuing to next batch...')
const timeIn5Minutes = new Date(Date.now() + 300_000).toISOString()
await props.workflow.update({ timeoutAt: timeIn5Minutes })
return
}
logger.info('Sync completed successfully')
await props.workflow.setCompleted()
} catch (error) {
logger.error('Error processing queue:', error)
await props.workflow.setFailed({ failureReason: 'Error processing queue' })
return
}
}
@@ -0,0 +1 @@
export * as processQueue from './process-queue'
@@ -0,0 +1,59 @@
import * as SyncQueue from '../../sync-queue'
import { createIntegrationTransferHandler } from '../../utils/create-integration-transfer-handler'
import * as bp from '.botpress'
export const handleEvent: bp.WorkflowHandlers['processQueue'] = async (props) => {
const logger = props.logger.withWorkflowId(props.workflow.id)
try {
const { syncQueue, key } = await SyncQueue.jobFileManager.getSyncQueue(props)
const hasKbTarget = syncQueue.some((item) => item.addToKbId !== undefined)
if (
!props.workflow.tags.integrationInstanceAlias ||
!props.workflow.tags.syncJobId ||
!props.workflow.tags.integrationDefinitionName ||
!props.workflow.tags.syncInitiatedAt
) {
throw new Error(
'Missing required workflow tags: integrationInstanceAlias, integrationDefinitionName, syncJobId, and syncInitiatedAt'
)
}
await props.workflow.acknowledgeStartOfProcessing()
const processResult = await SyncQueue.queueProcessor.processQueue({
logger,
syncQueue,
fileRepository: props.client,
integration: createIntegrationTransferHandler({
integrationName: props.workflow.tags.integrationDefinitionName,
integrationAlias: props.workflow.tags.integrationInstanceAlias,
client: props.client,
transferFileToBotpressAlias: props.workflow.input.transferFileToBotpressAlias,
shouldIndex: hasKbTarget,
}),
updateSyncQueue: (params) =>
SyncQueue.jobFileManager.updateSyncQueue(props, key, params.syncQueue, {
syncJobId: props.workflow.tags.syncJobId!,
integrationName: props.workflow.tags.integrationDefinitionName!,
integrationInstanceAlias: props.workflow.tags.integrationInstanceAlias!,
syncInitiatedAt: props.workflow.tags.syncInitiatedAt!,
}),
})
if (processResult.finished === 'batch') {
logger.info('Batch sync success. Continuing to next batch...')
const timeIn5Minutes = new Date(Date.now() + 300_000).toISOString()
await props.workflow.update({ timeoutAt: timeIn5Minutes })
return
}
logger.info('Sync completed successfully')
await props.workflow.setCompleted()
} catch (error) {
logger.error('Error processing queue:', error)
await props.workflow.setFailed({ failureReason: 'Error processing queue' })
return
}
}
@@ -0,0 +1 @@
export * as processQueue from './process-queue'
@@ -0,0 +1,5 @@
import * as bp from '.botpress'
export const handleEvent: bp.WorkflowHandlers['processQueue'] = async (props) => {
await props.workflow.setFailed({ failureReason: 'Workflow timed out' })
}
+82
View File
@@ -0,0 +1,82 @@
import * as actions from './actions'
import * as hooks from './hooks'
import * as onEventAggregate from './hooks/on-event/aggregate-file-changes'
import * as onEventFileCreated from './hooks/on-event/file-created'
import * as onEventFileDeleted from './hooks/on-event/file-deleted'
import * as onEventFileUpdated from './hooks/on-event/file-updated'
import * as onEventFolderDeleted from './hooks/on-event/folder-deleted-recursive'
import * as onEventScheduledSync from './hooks/on-event/scheduled-sync'
import * as bp from '.botpress'
const plugin = new bp.Plugin({
actions: {
syncFilesToBotpress: (props) => actions.syncFilesToBotpress.callAction(props),
listSynchronizationOperations: (props) => actions.listSynchronizationOperations.callAction(props),
checkSynchronizationStatus: (props) => actions.checkSynchronizationStatus.callAction(props),
},
})
plugin.on.event('files-readonly:fileDeleted', async (props) => {
try {
await onEventFileDeleted.handleEvent(props)
} catch (error) {
props.logger.error(`fileDeleted error: ${error instanceof Error ? error.message : String(error)}`)
}
})
plugin.on.event('files-readonly:folderDeletedRecursive', async (props) => {
try {
await onEventFolderDeleted.handleEvent(props)
} catch (error) {
props.logger.error(`folderDeletedRecursive error: ${error instanceof Error ? error.message : String(error)}`)
}
})
plugin.on.event('files-readonly:aggregateFileChanges', async (props) => {
try {
await onEventAggregate.handleEvent(props)
} catch (error) {
props.logger.error(`aggregateFileChanges error: ${error instanceof Error ? error.message : String(error)}`)
}
})
plugin.on.event('files-readonly:fileCreated', async (props) => {
try {
await onEventFileCreated.handleEvent(props)
} catch (error) {
props.logger.error(`fileCreated error: ${error instanceof Error ? error.message : String(error)}`)
}
})
plugin.on.event('files-readonly:fileUpdated', async (props) => {
try {
await onEventFileUpdated.handleEvent(props)
} catch (error) {
props.logger.error(`fileUpdated error: ${error instanceof Error ? error.message : String(error)}`)
}
})
plugin.on.event('scheduledSync', async (props) => {
try {
await onEventScheduledSync.handleEvent(props)
} catch (error) {
props.logger.error(`scheduledSync error: ${error instanceof Error ? error.message : String(error)}`)
}
})
plugin.on.workflowStart('processQueue', async (props) => {
props.logger.info('processQueue workflow started', props.workflow.tags)
await hooks.onWorkflowStart.processQueue.handleEvent(props)
})
plugin.on.workflowContinue('processQueue', async (props) => {
props.logger.info('processQueue workflow continued', props.workflow.tags)
await hooks.onWorkflowContinue.processQueue.handleEvent(props)
})
plugin.on.workflowTimeout('processQueue', async (props) => {
props.logger.info('processQueue workflow timed out', props.workflow.tags)
await hooks.onWorkflowTimeout.processQueue.handleEvent(props)
})
export default plugin
@@ -0,0 +1,129 @@
import * as sdk from '@botpress/sdk'
import { describe, it, expect, vi, type Mocked } from 'vitest'
import { processQueueFile, type ProcessFileProps } from './file-processor'
import type * as types from '../types'
const FILE_1 = {
id: 'file1',
type: 'file',
name: 'file1.txt',
absolutePath: '/path/to/file1.txt',
sizeInBytes: 100,
status: 'pending',
} as const satisfies types.SyncQueueItem
describe.concurrent('processQueue', () => {
const getMocks = () => ({
fileRepository: {
listFiles: vi.fn(),
deleteFile: vi.fn(),
updateFileMetadata: vi.fn(),
} as const satisfies Mocked<ProcessFileProps['fileRepository']>,
integration: {
name: 'test-integration',
alias: 'test-integration-alias',
transferFileToBotpress: vi.fn(),
} as const satisfies Mocked<ProcessFileProps['integration']>,
logger: new sdk.BotLogger(),
})
it('should handle errors by modifying the queue item', async () => {
// Arrange
const mocks = getMocks()
mocks.fileRepository.listFiles.mockResolvedValueOnce({ files: [] })
// First transfer fails, second succeeds:
mocks.integration.transferFileToBotpress.mockRejectedValueOnce(new Error('Transfer failed'))
// Act
const result = processQueueFile({
fileToSync: FILE_1,
...mocks,
})
// Assert
await expect(result).resolves.toMatchObject({ status: 'errored', errorMessage: 'Transfer failed' })
expect(mocks.integration.transferFileToBotpress).toHaveBeenCalledTimes(1)
expect(mocks.fileRepository.updateFileMetadata).not.toHaveBeenCalled()
})
it('should handle listFiles failure by marking the queue item as errored', async () => {
// Arrange
const mocks = getMocks()
mocks.fileRepository.listFiles.mockRejectedValueOnce(new Error('Files API unavailable'))
// Act
const result = processQueueFile({
fileToSync: FILE_1,
...mocks,
})
// Assert
await expect(result).resolves.toMatchObject({ status: 'errored', errorMessage: 'Files API unavailable' })
expect(mocks.integration.transferFileToBotpress).not.toHaveBeenCalled()
expect(mocks.fileRepository.deleteFile).not.toHaveBeenCalled()
expect(mocks.fileRepository.updateFileMetadata).not.toHaveBeenCalled()
})
it('should properly set metadata tags after transferring file', async () => {
// Arrange
const mocks = getMocks()
mocks.fileRepository.listFiles.mockResolvedValueOnce({ files: [] })
mocks.integration.transferFileToBotpress.mockResolvedValueOnce({ botpressFileId: FILE_1.id })
// Act
const result = processQueueFile({
fileToSync: FILE_1,
...mocks,
})
// Assert
await expect(result).resolves.toMatchObject({ status: 'newly-synced' })
expect(mocks.fileRepository.updateFileMetadata).toHaveBeenCalledWith({
id: FILE_1.id,
tags: {
integrationName: 'test-integration',
integrationAlias: 'test-integration-alias',
externalId: FILE_1.id,
externalSize: FILE_1.sizeInBytes.toString(),
externalContentHash: null,
externalPath: FILE_1.absolutePath,
kbId: null,
modalities: null,
source: null,
title: null,
},
})
})
it('should assign to kb if needed after transferring file', async () => {
// Arrange
const mocks = getMocks()
mocks.fileRepository.listFiles.mockResolvedValueOnce({ files: [] })
mocks.integration.transferFileToBotpress.mockResolvedValueOnce({ botpressFileId: FILE_1.id })
// Act
const result = processQueueFile({
fileToSync: { ...FILE_1, addToKbId: 'kb1' },
...mocks,
})
// Assert
await expect(result).resolves.toMatchObject({ status: 'newly-synced' })
expect(mocks.fileRepository.updateFileMetadata).toHaveBeenCalledWith({
id: FILE_1.id,
tags: {
integrationName: 'test-integration',
integrationAlias: 'test-integration-alias',
externalId: FILE_1.id,
externalSize: FILE_1.sizeInBytes.toString(),
externalContentHash: null,
externalPath: FILE_1.absolutePath,
kbId: 'kb1',
modalities: '["text"]',
source: 'knowledge-base',
title: FILE_1.name,
},
})
})
})
@@ -0,0 +1,151 @@
import * as sdk from '@botpress/sdk'
import type * as models from '../../definitions/models'
import type * as types from '../types'
export type ProcessFileProps = {
fileToSync: Readonly<types.SyncQueueItem>
logger: sdk.BotLogger
fileRepository: {
listFiles: (params: { tags: Record<string, string> }) => Promise<{ files: types.FilesApiFile[] }>
deleteFile: (params: { id: string }) => Promise<unknown>
updateFileMetadata: (params: { id: string; tags: Record<string, string | null> }) => Promise<unknown>
}
integration: {
name: string
alias: string
transferFileToBotpress: (params: {
file: models.FileWithPath
fileKey: string
}) => Promise<{ botpressFileId: string }>
}
}
export const processQueueFile = async (props: ProcessFileProps): Promise<types.SyncQueueItem> => {
const fileToSync = structuredClone(props.fileToSync) as types.SyncQueueItem
let existingFile: types.FilesApiFile | undefined
try {
existingFile = await _getExistingFileFromFilesApi(props, fileToSync)
} catch (thrown: unknown) {
const err: Error = thrown instanceof Error ? thrown : new Error(String(thrown))
fileToSync.status = 'errored'
fileToSync.errorMessage = err.message
props.logger.error(`Error while checking existing file ${fileToSync.absolutePath}: ${err.message}`)
return fileToSync
}
const shouldUploadFile = _shouldUploadFile(fileToSync, existingFile)
if (!shouldUploadFile) {
fileToSync.status = 'already-synced'
return fileToSync
}
const existingTags = existingFile?.tags ?? {}
await _deleteExistingFileFromFilesApi(props, existingFile)
await _transferFileToBotpress(props, fileToSync, existingTags)
if (fileToSync.status !== 'errored') {
fileToSync.status = 'newly-synced'
}
return fileToSync
}
const _getExistingFileFromFilesApi = async (
props: ProcessFileProps,
fileToSync: models.FileWithPath
): Promise<types.FilesApiFile | undefined> => {
const { files: existingFiles } = await props.fileRepository.listFiles({
tags: {
externalId: fileToSync.id,
integrationName: props.integration.name,
integrationAlias: props.integration.alias,
},
})
if (existingFiles.length === 0) {
return
}
return existingFiles[0]!
}
const _shouldUploadFile = (fileToSync: models.FileWithPath, existingFile?: types.FilesApiFile) => {
if (!existingFile) {
return true
}
const existingHash = existingFile.tags['externalContentHash']
const newHash = fileToSync.contentHash
if (existingHash && newHash) {
return existingHash !== newHash
}
const existingSize = existingFile.tags['externalSize']
const newSize = fileToSync.sizeInBytes?.toString()
if (existingSize && newSize) {
return existingSize !== newSize
}
return true
}
const _deleteExistingFileFromFilesApi = async (props: ProcessFileProps, existingFile?: types.FilesApiFile) => {
if (!existingFile) {
return
}
try {
await props.fileRepository.deleteFile({ id: existingFile.id })
} catch (error) {
props.logger.warn(`Failed to delete existing file: ${error instanceof Error ? error.message : String(error)}`)
}
}
const _transferFileToBotpress = async (
props: ProcessFileProps,
fileToSync: types.SyncQueueItem,
existingTags: Record<string, string> = {}
) => {
let botpressFileId: string
try {
const result = await props.integration.transferFileToBotpress({
file: fileToSync,
fileKey: `${props.integration.alias}:${fileToSync.absolutePath}`,
})
botpressFileId = result.botpressFileId
} catch (thrown: unknown) {
const err: Error = thrown instanceof Error ? thrown : new Error(String(thrown))
fileToSync.status = 'errored'
fileToSync.errorMessage = err.message
props.logger.error(`Error while uploading file ${fileToSync.absolutePath}: ${err.message}`)
return
}
try {
await props.fileRepository.updateFileMetadata({
id: botpressFileId,
tags: {
...existingTags,
integrationName: props.integration.name,
integrationAlias: props.integration.alias,
externalId: fileToSync.id,
externalSize: fileToSync.sizeInBytes?.toString() ?? null,
externalContentHash: fileToSync.contentHash ?? null,
externalPath: fileToSync.absolutePath,
kbId: fileToSync.addToKbId ?? null,
source: fileToSync.addToKbId ? 'knowledge-base' : null,
title: fileToSync.addToKbId ? fileToSync.name : null,
modalities: fileToSync.addToKbId ? '["text"]' : null,
},
})
} catch (thrown: unknown) {
const err: Error = thrown instanceof Error ? thrown : new Error(String(thrown))
fileToSync.status = 'errored'
fileToSync.errorMessage = err.message
props.logger.error(
`File ${fileToSync.absolutePath} was uploaded (id: ${botpressFileId}) but metadata update failed: ${err.message}. This file may be orphaned and should be cleaned up manually if the next sync does not overwrite it.`
)
}
}
@@ -0,0 +1,3 @@
export * as queueProcessor from './queue-processor'
export * as fileProcessor from './file-processor'
export * as jobFileManager from './job-file-manager'
@@ -0,0 +1,57 @@
import { QUEUE_ITEM_SCHEMA } from '../types'
import type * as types from '../types'
import * as utils from '../utils'
import * as bp from '.botpress'
export const getSyncQueue = async (
props: bp.WorkflowHandlerProps['processQueue']
): Promise<{ syncQueue: types.SyncQueue; key: string }> => {
const { jobFileContent, key } = await _retrieveJobFile(props).catch((thrown: unknown) => {
const err: Error = thrown instanceof Error ? thrown : new Error(String(thrown))
throw new Error(`Failed to retrieve job file: ${err.message}`)
})
const syncQueue: types.SyncQueue = []
const syncQueueGenerator = utils.jsonl.parseJsonLines(jobFileContent, QUEUE_ITEM_SCHEMA)
for (const item of syncQueueGenerator) {
if ('error' in item) {
props.logger.error('Error while parsing line in job file. This line will be ignored.', item)
continue
}
syncQueue.push(item.value)
}
return { syncQueue, key }
}
const _retrieveJobFile = async (
props: bp.WorkflowHandlerProps['processQueue']
): Promise<{ jobFileContent: string; key: string }> => {
const { file: jobFile } = await props.client.getFile({
id: props.workflow.input.jobFileId,
})
const res = await fetch(jobFile.url, { signal: AbortSignal.timeout(30000) })
if (!res.ok) {
throw new Error(`Failed to download sync queue file: HTTP ${res.status} ${res.statusText}`)
}
const jobFileContent = await res.text()
return { jobFileContent, key: jobFile.key }
}
export const updateSyncQueue = async (
props: types.CommonProps,
key: string,
syncQueue: types.SyncQueue,
tags?: Record<string, string>
): Promise<string> => {
const { file: jobFile } = await props.client.uploadFile({
key,
content: syncQueue.map((item) => JSON.stringify(item)).join('\n') + '\n',
tags,
})
return jobFile.id
}
@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest'
import { findBatchEndCursor } from './queue-batching'
import { MAX_BATCH_SIZE_BYTES as maxBatchSize } from '../consts'
describe.concurrent('findBatchEndCursor', () => {
it.each([
{ startCursor: 0, files: [{ sizeInBytes: 100 }, { sizeInBytes: 200 }], expectedEndCursors: [2] },
{ startCursor: 0, files: [{ sizeInBytes: maxBatchSize }, { sizeInBytes: 200 }], expectedEndCursors: [1, 2] },
{ startCursor: 0, files: [{ sizeInBytes: maxBatchSize + 100 }, { sizeInBytes: 200 }], expectedEndCursors: [1, 2] },
{
startCursor: 0,
files: [{ sizeInBytes: 200 }, { sizeInBytes: 200 }, { sizeInBytes: maxBatchSize }, { sizeInBytes: 200 }],
expectedEndCursors: [2, 3, 4],
},
{
startCursor: 0,
files: [{ sizeInBytes: 200 }, { sizeInBytes: 200 }, { sizeInBytes: maxBatchSize + 100 }, { sizeInBytes: 200 }],
expectedEndCursors: [2, 3, 4],
},
{
startCursor: 0,
files: [{ sizeInBytes: 200 }, { sizeInBytes: 200 }, { sizeInBytes: maxBatchSize - 200 }, { sizeInBytes: 200 }],
expectedEndCursors: [2, 4],
},
{
startCursor: 2,
files: [
{ sizeInBytes: 200 },
{ sizeInBytes: 200 },
{ sizeInBytes: 200 },
{ sizeInBytes: 200 },
{ sizeInBytes: maxBatchSize - 200 },
{ sizeInBytes: 200 },
],
expectedEndCursors: [4, 6],
},
{
startCursor: 0,
files: [{ sizeInBytes: 200 }, { sizeInBytes: 200 }, { sizeInBytes: maxBatchSize + 100 }],
expectedEndCursors: [2, 3],
},
])(
'should correctly calculate the end cursors for a batch of files',
({ startCursor, files, expectedEndCursors }) => {
// Act
const endCursors: number[] = []
let currentCursor = startCursor
while (currentCursor < files.length) {
currentCursor = findBatchEndCursor({ startCursor: currentCursor, files }).endCursor
endCursors.push(currentCursor)
}
// Assert
expect(endCursors).toStrictEqual(expectedEndCursors)
}
)
})
@@ -0,0 +1,41 @@
import { MAX_BATCH_SIZE_BYTES } from '../consts'
import type * as types from '../types'
type SimpleFile = Pick<types.SyncQueueItem, 'sizeInBytes'>
export const findBatchEndCursor = ({
startCursor: unsafeStartCursor,
files,
}: {
startCursor: number
files: SimpleFile[]
}): { endCursor: number } => {
const startCursor = Math.min(Math.max(unsafeStartCursor, 0), files.length - 1)
let currentBatchSize = 0
let endCursor = files.length
for (let newCursor = startCursor; newCursor < files.length; newCursor++) {
const fileToSync = files[newCursor]
const fileSizeInBytes = _getFileSize(fileToSync!)
currentBatchSize += fileSizeInBytes
// First file is always included even if it exceeds the batch size:
if (newCursor > startCursor && currentBatchSize > MAX_BATCH_SIZE_BYTES) {
endCursor = newCursor
break
}
}
return { endCursor }
}
const _getFileSize = (file: SimpleFile) => {
if (file.sizeInBytes === undefined) {
// If a file has no size, we assume it takes up half the batch size limit.
// This should be relatively safe, since we'd process at most two files of
// unknown size in a batch, and we don't want to just skip them.
return MAX_BATCH_SIZE_BYTES / 2
}
return file.sizeInBytes
}
@@ -0,0 +1,177 @@
import * as sdk from '@botpress/sdk'
import { describe, it, expect, vi, type Mocked, type Mock } from 'vitest'
import { processQueue, type ProcessQueueProps } from './queue-processor'
import type * as types from '../types'
import { MAX_BATCH_SIZE_BYTES } from '../consts'
const FILE_1 = {
id: 'file1',
type: 'file',
name: 'file1.txt',
absolutePath: '/path/to/file1.txt',
sizeInBytes: 100,
status: 'pending',
} as const satisfies types.SyncQueueItem
const FILE_2 = {
id: 'file2',
type: 'file',
name: 'file2.txt',
absolutePath: '/path/to/file2.txt',
sizeInBytes: 200,
status: 'pending',
} as const satisfies types.SyncQueueItem
const LARGE_FILE = {
id: 'largefile',
type: 'file',
name: 'largefile.txt',
absolutePath: '/path/to/largefile.txt',
sizeInBytes: MAX_BATCH_SIZE_BYTES - 200,
status: 'pending',
} as const satisfies types.SyncQueueItem
describe.concurrent('processQueue', () => {
const getMocks = () => ({
fileRepository: {
listFiles: vi.fn(),
deleteFile: vi.fn(),
updateFileMetadata: vi.fn(),
} as const satisfies Mocked<ProcessQueueProps['fileRepository']>,
integration: {
name: 'test-integration',
alias: 'test-integration-alias',
transferFileToBotpress: vi.fn(),
} as const satisfies Mocked<ProcessQueueProps['integration']>,
updateSyncQueue: vi.fn() as Mock<ProcessQueueProps['updateSyncQueue']>,
logger: new sdk.BotLogger(),
})
it('should process all files in queue when size is within batch limit', async () => {
// Arrange
const mocks = getMocks()
mocks.fileRepository.listFiles.mockResolvedValueOnce({ files: [] })
mocks.fileRepository.listFiles.mockResolvedValueOnce({ files: [] })
mocks.integration.transferFileToBotpress.mockResolvedValueOnce({ botpressFileId: FILE_1.id })
mocks.integration.transferFileToBotpress.mockResolvedValueOnce({ botpressFileId: FILE_2.id })
// Act
const result = processQueue({
syncQueue: [FILE_1, FILE_2],
...mocks,
})
// Assert
await expect(result).resolves.toEqual({ finished: 'all' })
expect(mocks.integration.transferFileToBotpress).toHaveBeenCalledTimes(2)
expect(mocks.fileRepository.updateFileMetadata).toHaveBeenCalledTimes(2)
expect(mocks.updateSyncQueue).toHaveBeenCalledTimes(1)
const updatedQueue = mocks.updateSyncQueue.mock.calls[0]?.[0].syncQueue
expect(updatedQueue?.[0]?.status).toBe('newly-synced')
expect(updatedQueue?.[1]?.status).toBe('newly-synced')
})
it('should process files in batches when size exceeds batch limit', async () => {
// Arrange
const mocks = getMocks()
mocks.fileRepository.listFiles.mockResolvedValueOnce({ files: [] })
mocks.fileRepository.listFiles.mockResolvedValueOnce({ files: [] })
mocks.integration.transferFileToBotpress.mockResolvedValueOnce({ botpressFileId: FILE_1.id })
mocks.integration.transferFileToBotpress.mockResolvedValueOnce({ botpressFileId: LARGE_FILE.id })
// Act
const result = processQueue({
syncQueue: [FILE_1, LARGE_FILE, FILE_2],
...mocks,
})
// Assert
await expect(result).resolves.toEqual({ finished: 'batch' })
expect(mocks.integration.transferFileToBotpress).toHaveBeenCalledTimes(2)
expect(mocks.updateSyncQueue).toHaveBeenCalledTimes(1)
const updatedQueue = mocks.updateSyncQueue.mock.calls[0]?.[0].syncQueue
expect(updatedQueue?.[0]?.status).toBe('newly-synced')
expect(updatedQueue?.[1]?.status).toBe('newly-synced')
expect(updatedQueue?.[2]?.status).toBe('pending')
})
it('should handle errors by continuing to the next file', async () => {
// Arrange
const mocks = getMocks()
mocks.fileRepository.listFiles.mockResolvedValueOnce({ files: [] })
mocks.fileRepository.listFiles.mockResolvedValueOnce({ files: [] })
// First transfer fails, second succeeds:
mocks.integration.transferFileToBotpress.mockRejectedValueOnce(new Error('Transfer failed'))
mocks.integration.transferFileToBotpress.mockResolvedValueOnce({ botpressFileId: FILE_2.id })
// Act
const result = processQueue({
syncQueue: [FILE_1, FILE_2],
...mocks,
})
// Assert
await expect(result).resolves.toEqual({ finished: 'all' })
expect(mocks.integration.transferFileToBotpress).toHaveBeenCalledTimes(2)
expect(mocks.fileRepository.updateFileMetadata).toHaveBeenCalledTimes(1)
const updatedQueue = mocks.updateSyncQueue.mock.calls[0]?.[0].syncQueue
expect(updatedQueue?.[0]?.status).toBe('errored')
expect(updatedQueue?.[0]?.errorMessage).toContain('Transfer failed')
expect(updatedQueue?.[1]?.status).toBe('newly-synced')
})
it('should handle complex batching', async () => {
// >>>>>>>>>>> FIRST BATCH <<<<<<<<<<<<<
// Arrange
const mocks = getMocks()
mocks.fileRepository.listFiles.mockResolvedValueOnce({ files: [] })
mocks.fileRepository.listFiles.mockResolvedValueOnce({ files: [] })
mocks.integration.transferFileToBotpress.mockResolvedValueOnce({ botpressFileId: FILE_2.id })
mocks.integration.transferFileToBotpress.mockResolvedValueOnce({ botpressFileId: FILE_1.id })
// Act
const firstRun = await processQueue({
syncQueue: [FILE_2, FILE_1, LARGE_FILE],
...mocks,
})
// Assert
expect(firstRun).toEqual({ finished: 'batch' })
expect(mocks.integration.transferFileToBotpress).toHaveBeenCalledTimes(2)
expect(mocks.updateSyncQueue).toHaveBeenCalledTimes(1)
const updatedQueue = mocks.updateSyncQueue.mock.calls[0]?.[0].syncQueue!
expect(updatedQueue[0]?.status).toBe('newly-synced')
expect(updatedQueue[1]?.status).toBe('newly-synced')
expect(updatedQueue[2]?.status).toBe('pending')
// >>>>>>>>>>> SECOND BATCH <<<<<<<<<<<<<
// Arrange
mocks.fileRepository.listFiles.mockResolvedValueOnce({ files: [] })
mocks.integration.transferFileToBotpress.mockResolvedValueOnce({ botpressFileId: LARGE_FILE.id })
// Act
const secondRun = await processQueue({
syncQueue: updatedQueue!,
...mocks,
})
// Assert
expect(secondRun).toEqual({ finished: 'all' })
expect(mocks.integration.transferFileToBotpress).toHaveBeenCalledTimes(3)
expect(mocks.updateSyncQueue).toHaveBeenCalledTimes(2)
const finalQueue = mocks.updateSyncQueue.mock.calls[1]?.[0].syncQueue!
expect(finalQueue[0]?.status).toBe('newly-synced')
expect(finalQueue[1]?.status).toBe('newly-synced')
expect(finalQueue[2]?.status).toBe('newly-synced')
})
})
@@ -0,0 +1,57 @@
import * as sdk from '@botpress/sdk'
import type * as models from '../../definitions/models'
import type * as types from '../types'
import { processQueueFile } from './file-processor'
import { findBatchEndCursor } from './queue-batching'
export type ProcessQueueProps = {
syncQueue: Readonly<types.SyncQueue>
logger: sdk.BotLogger
fileRepository: {
listFiles: (params: { tags: Record<string, string> }) => Promise<{ files: types.FilesApiFile[] }>
deleteFile: (params: { id: string }) => Promise<unknown>
updateFileMetadata: (params: { id: string; tags: Record<string, string | null> }) => Promise<unknown>
}
integration: {
name: string
alias: string
transferFileToBotpress: (params: {
file: models.FileWithPath
fileKey: string
}) => Promise<{ botpressFileId: string }>
}
updateSyncQueue: (props: { syncQueue: types.SyncQueue }) => Promise<unknown>
}
export const processQueue = async (props: ProcessQueueProps) => {
// Short-circuit if the sync queue is empty:
if (!props.syncQueue?.length) {
props.logger.info('Sync queue is empty. Nothing to process.')
return { finished: 'all' } as const
}
const syncQueue = structuredClone(props.syncQueue) as types.SyncQueue
const startCursor = syncQueue.findIndex((file) => file.status === 'pending')
if (startCursor < 0) {
props.logger.info('No files left to process in the sync queue.')
return { finished: 'all' } as const
}
const { endCursor } = findBatchEndCursor({ startCursor, files: syncQueue })
const filesInBatch = syncQueue.slice(startCursor, endCursor)
for (const fileToSync of filesInBatch) {
const processedFile = await processQueueFile({ ...props, fileToSync })
Object.assign(fileToSync, processedFile)
}
await props.updateSyncQueue({ syncQueue })
const hasPendingBeyondBatch = syncQueue.slice(endCursor).some((f) => f.status === 'pending')
if (hasPendingBeyondBatch) {
return { finished: 'batch' } as const
}
return { finished: 'all' } as const
}
+28
View File
@@ -0,0 +1,28 @@
import * as sdk from '@botpress/sdk'
import { CommonHandlerProps } from '@botpress/sdk/dist/plugin'
import * as models from '../definitions/models'
import type * as bp from '.botpress'
type TPlugin = sdk.DefaultPlugin<bp.TPlugin>
export type SyncQueueItem = models.FileWithPath & {
status: 'pending' | 'newly-synced' | 'already-synced' | 'errored'
errorMessage?: string
addToKbId?: string
}
export type SyncQueue = SyncQueueItem[]
export type CommonProps = CommonHandlerProps<TPlugin>
export type FilesApiFile = {
id: string
tags: Record<string, string>
}
export type Workflow = Awaited<ReturnType<bp.Client['listWorkflows']>>['workflows'][number]
export const QUEUE_ITEM_SCHEMA = models.FILE_WITH_PATH.extend({
status: sdk.z.enum(['pending', 'newly-synced', 'already-synced', 'errored']),
errorMessage: sdk.z.string().optional(),
addToKbId: sdk.z.string().optional(),
})
@@ -0,0 +1,49 @@
import type * as models from '../../definitions/models'
const TRANSFER_FILE_ACTION_NAME = 'filesReadonlyTransferFileToBotpress'
export type CreateIntegrationTransferHandlerProps = {
integrationName: string
integrationAlias: string
client: {
callAction: (params: {
type: string
input: {
file: models.FileWithPath
fileKey: string
shouldIndex: boolean
}
}) => Promise<{ output: { botpressFileId: string } }>
}
transferFileToBotpressAlias?: string
shouldIndex?: boolean
}
export function createIntegrationTransferHandler(props: CreateIntegrationTransferHandlerProps): {
name: string
alias: string
transferFileToBotpress: (params: {
file: models.FileWithPath
fileKey: string
}) => Promise<{ botpressFileId: string }>
} {
const actionName = props.transferFileToBotpressAlias ?? TRANSFER_FILE_ACTION_NAME
const shouldIndex = props.shouldIndex ?? true
return {
name: props.integrationName,
alias: props.integrationAlias,
async transferFileToBotpress({ file, fileKey }) {
const { output } = await props.client.callAction({
type: `${props.integrationAlias}:${actionName}`,
input: {
file,
fileKey,
shouldIndex,
},
})
return { botpressFileId: output.botpressFileId }
},
}
}
@@ -0,0 +1,28 @@
export const randomUUID = async (): Promise<string> => {
const crypto = await _getWebCrypto()
return crypto.randomUUID()
}
type _WebCrypto = { randomUUID: () => string }
let _webCrypto: _WebCrypto | undefined
const _getWebCrypto = async (): Promise<_WebCrypto> => {
if (!_webCrypto) {
if (typeof (globalThis as any).crypto?.randomUUID === 'function') {
_webCrypto = (globalThis as any).crypto
} else if (typeof process !== 'undefined' && process.versions?.node) {
try {
const nodeCrypto = await import('crypto')
_webCrypto = nodeCrypto.webcrypto
} catch (thrown: unknown) {
const error: Error = thrown instanceof Error ? thrown : new Error(String(thrown))
throw new Error(`Failed to import 'crypto' module: ${error.message}`)
}
}
if (!_webCrypto || typeof _webCrypto.randomUUID !== 'function') {
throw new Error('No suitable web crypto implementation available')
}
}
return _webCrypto
}
@@ -0,0 +1,89 @@
import type * as sdk from '@botpress/sdk'
type ListItemsInFolder = (input: {
folderId?: string
nextToken?: string
filters?: { itemType?: 'file' | 'folder' }
}) => Promise<{
items: Array<
| {
id: string
type: 'file'
name: string
parentId?: string
absolutePath?: string
sizeInBytes?: number
lastModifiedDate?: string
contentHash?: string
}
| {
id: string
type: 'folder'
name: string
parentId?: string
absolutePath?: string
}
>
meta: { nextToken?: string }
}>
export type EnumeratedFile = {
id: string
name: string
absolutePath: string
sizeInBytes?: number
contentHash?: string
}
/**
* Recursively enumerates all files within a given folder by traversing subfolders
* and handling pagination via nextToken.
*/
export const enumerateFilesInFolder = async (props: {
listItemsInFolder: ListItemsInFolder
folderId: string
folderPath: string
logger: sdk.BotLogger
}): Promise<EnumeratedFile[]> => {
const { listItemsInFolder, folderId, folderPath, logger } = props
const files: EnumeratedFile[] = []
const pendingFolders: Array<{ folderId?: string; absolutePath: string }> = [{ folderId, absolutePath: folderPath }]
let folderIndex = 0
while (folderIndex < pendingFolders.length) {
const currentFolder = pendingFolders[folderIndex++]!
let nextToken: string | undefined
do {
const response = await listItemsInFolder({
folderId: currentFolder.folderId,
nextToken,
})
for (const item of response.items) {
const basePath = currentFolder.absolutePath.endsWith('/')
? currentFolder.absolutePath
: `${currentFolder.absolutePath}/`
if (item.type === 'folder') {
const subfolderPath = item.absolutePath ?? `${basePath}${item.name}/`
pendingFolders.push({ folderId: item.id, absolutePath: subfolderPath })
} else {
const filePath = item.absolutePath ?? `${basePath}${item.name}`
files.push({
id: item.id,
name: item.name,
absolutePath: filePath,
sizeInBytes: item.sizeInBytes,
contentHash: item.contentHash,
})
}
}
nextToken = response.meta.nextToken
} while (nextToken)
}
logger.info(`Enumerated ${files.length} files in folder "${folderPath}"`)
return files
}
@@ -0,0 +1,10 @@
import * as sdk from '@botpress/sdk'
export function extractIntegrationAlias(eventType: string, logger: sdk.BotLogger): string | null {
const integrationAlias = eventType.split(':')[0]
if (!integrationAlias) {
logger.warn(`Could not extract integration alias from event type: ${eventType}`)
return null
}
return integrationAlias
}
@@ -0,0 +1,50 @@
type FolderConfig = {
syncNewFiles?: boolean
path?: string
integrationDefinitionName?: string
integrationInstanceAlias?: string
transferFileToBotpressAlias?: string
}
type FolderMatch = {
kbId: string
folderId: string
syncNewFiles: boolean
integrationDefinitionName?: string
integrationInstanceAlias?: string
transferFileToBotpressAlias?: string
}
export function findFolderByPath(
settings: Record<string, Record<string, FolderConfig>>,
filePath: string,
integrationAlias?: string
): FolderMatch | undefined {
let bestMatch: FolderMatch | undefined
let bestMatchLength = 0
for (const [kbId, folders] of Object.entries(settings)) {
for (const [folderId, folderSettings] of Object.entries(folders)) {
const rawPath = folderSettings.path
const folderPath = rawPath && !rawPath.endsWith('/') ? `${rawPath}/` : rawPath
if (
folderPath &&
filePath.startsWith(folderPath) &&
folderPath.length > bestMatchLength &&
(!integrationAlias || folderSettings.integrationInstanceAlias === integrationAlias)
) {
bestMatchLength = folderPath.length
bestMatch = {
kbId,
folderId,
syncNewFiles: folderSettings.syncNewFiles ?? false,
integrationDefinitionName: folderSettings.integrationDefinitionName,
integrationInstanceAlias: folderSettings.integrationInstanceAlias,
transferFileToBotpressAlias: folderSettings.transferFileToBotpressAlias,
}
}
}
}
return bestMatch
}
@@ -0,0 +1 @@
export const getAliasedName = (name: string, alias?: string) => (alias && alias !== name ? `${name}(${alias})` : name)
@@ -0,0 +1 @@
export * as jsonl from './json-lines'
@@ -0,0 +1 @@
export * from './parser'
@@ -0,0 +1,157 @@
import { describe, it, expect } from 'vitest'
import * as sdk from '@botpress/sdk'
import { parseJsonLines } from './parser'
describe.concurrent('parseJsonLines', () => {
const STRING_SCHEMA = sdk.z.string()
const NUMBER_SCHEMA = sdk.z.number()
const USER_SCHEMA = sdk.z.object({
id: sdk.z.number(),
name: sdk.z.string(),
email: sdk.z.string().email(),
})
it('should parse valid JSON lines with a string schema', () => {
// Arrange
const input = '"hello"\n"world"\n"test"'
// Act
const result = Array.from(parseJsonLines(input, STRING_SCHEMA))
// Assert
expect(result).toMatchObject([{ value: 'hello' }, { value: 'world' }, { value: 'test' }])
})
it('should parse valid JSON lines with an object schema', () => {
// Arrange
const input =
'{"id": 1, "name": "John", "email": "john@example.com"}\n' +
'{"id": 2, "name": "Jane", "email": "jane@example.com"}\n' +
'{"id": 3, "name": "Bob", "email": "bob@example.com"}'
// Act
const result = Array.from(parseJsonLines(input, USER_SCHEMA))
// Assert
expect(result).toMatchObject([
{
value: { id: 1, name: 'John', email: 'john@example.com' },
},
{
value: { id: 2, name: 'Jane', email: 'jane@example.com' },
},
{ value: { id: 3, name: 'Bob', email: 'bob@example.com' } },
])
})
it('should handle empty input strings', () => {
// Arrange
const input = ''
// Act
const result = Array.from(parseJsonLines(input, STRING_SCHEMA))
// Assert
expect(result).toStrictEqual([])
})
it('should handle input with only whitespace', () => {
// Arrange
const input = ' \n \n\t '
// Act
const result = Array.from(parseJsonLines(input, STRING_SCHEMA))
// Assert
expect(result).toStrictEqual([])
})
it('should skip empty lines', () => {
// Arrange
const input = '"first"\n\n"second"\n\n\n"third"'
// Act
const result = Array.from(parseJsonLines(input, STRING_SCHEMA))
// Assert
expect(result).toMatchObject([{ value: 'first' }, { value: 'second' }, { value: 'third' }])
})
it('should handle trailing newlines', () => {
// Arrange
const input = '"line1"\n"line2"\n'
// Act
const result = Array.from(parseJsonLines(input, STRING_SCHEMA))
// Assert
expect(result).toMatchObject([{ value: 'line1' }, { value: 'line2' }])
})
it('should handle input without newlines', () => {
// Arrange
const input = '"single line"'
// Act
const result = Array.from(parseJsonLines(input, STRING_SCHEMA))
// Assert
expect(result).toMatchObject([{ value: 'single line' }])
})
it('should return a Zod error when a line fails Zod validation', () => {
// Arrange
const input = '{"id": 1, "name": "John", "email": "invalid-email"}'
// Act
const result = Array.from(parseJsonLines(input, USER_SCHEMA))
// Assert
expect(result).toMatchObject([
{ rawLine: '{"id": 1, "name": "John", "email": "invalid-email"}', error: expect.any(Error) },
])
})
it('should parse to the end, even with errors', () => {
// Arrange
const input = '1\n2\n"not a number"\n4'
// Act
const result = Array.from(parseJsonLines(input, NUMBER_SCHEMA))
// Assert
expect(result).toStrictEqual([
{ rawLine: '1', value: 1 },
{ rawLine: '2', value: 2 },
{ rawLine: '"not a number"', error: expect.any(Error) },
{ rawLine: '4', value: 4 },
])
})
it('should handle different line endings (CRLF)', () => {
// Arrange
const input = '"line1"\r\n"line2"\r\n"line3"'
// Act
const result = Array.from(parseJsonLines(input, STRING_SCHEMA))
// Assert
expect(result).toMatchObject([{ value: 'line1' }, { value: 'line2' }, { value: 'line3' }])
})
it('should parse properly with schema containing optional fields', () => {
// Arrange
const optionalSchema = sdk.z.object({
id: sdk.z.number(),
name: sdk.z.string(),
age: sdk.z.number().optional(),
})
const input = '{"id": 1, "name": "John"}\n{"id": 2, "name": "Jane", "age": 25}'
// Act
const result = Array.from(parseJsonLines(input, optionalSchema))
// Assert
expect(result).toMatchObject([{ value: { id: 1, name: 'John' } }, { value: { id: 2, name: 'Jane', age: 25 } }])
})
})
@@ -0,0 +1,26 @@
import { z } from '@botpress/sdk'
type _JsonParseResult<T> = { rawLine: string } & ({ value: T } | { error: Error })
export function* parseJsonLines<TLineSchema extends z.ZodTypeAny>(
rawJsonLines: string,
zodSchema: TLineSchema
): Generator<_JsonParseResult<z.infer<TLineSchema>>, void, undefined> {
let startCursor = 0
for (let endCursor = 0; endCursor <= rawJsonLines.length; endCursor++) {
if (rawJsonLines[endCursor] === '\n' || endCursor === rawJsonLines.length) {
const line = rawJsonLines.slice(startCursor, endCursor).trim()
startCursor = endCursor + 1
if (line) {
try {
yield { rawLine: line, value: zodSchema.parse(JSON.parse(line)) }
} catch (thrown: unknown) {
const err: Error = thrown instanceof Error ? thrown : new Error(String(thrown))
yield { rawLine: line, error: err }
}
}
}
}
}
@@ -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