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
+11
View File
@@ -0,0 +1,11 @@
.ignore.me.*
node_modules
tsconfig.tsbuildinfo
/src
/e2e
/tsconfig.json
/templates/*/dist
/templates/*/node_modules
build.ts
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env node
require("./dist/cli.js");
+13
View File
@@ -0,0 +1,13 @@
import esbuild from 'esbuild'
import glob from 'glob'
const entryPoints = glob.sync('./src/**/*.ts')
void esbuild.build({
entryPoints,
bundle: false,
platform: 'node',
format: 'cjs',
external: [],
outdir: './dist',
sourcemap: true,
})
+13
View File
@@ -0,0 +1,13 @@
import { Client } from '@botpress/client'
export type ApiBot = Awaited<ReturnType<Client['listBots']>>['bots'][0]
export const fetchAllBots = async (client: Client): Promise<ApiBot[]> => await client.list.bots({}).collect()
export type ApiIntegration = Awaited<ReturnType<Client['listIntegrations']>>['integrations'][0]
export const fetchAllIntegrations = async (client: Client): Promise<ApiIntegration[]> =>
await client.list.integrations({}).collect()
export type ApiInterface = Awaited<ReturnType<Client['listInterfaces']>>['interfaces'][0]
export type ApiPlugin = Awaited<ReturnType<Client['listPlugins']>>['plugins'][0]
export const fetchAllPlugins = async (client: Client): Promise<ApiPlugin[]> => await client.list.plugins({}).collect()
+33
View File
@@ -0,0 +1,33 @@
const noBuild = false
const dryRun = false
const secrets = [] satisfies string[]
const sourceMap = false
const watch = true
const verbose = false
const confirm = true
const json = false
const allowDeprecated = false
const isPublic = false
const visibility = 'private' as const
const minify = true
const profile = undefined
const url = undefined
const bypassBreakingChangeDetection = false
export default {
minify,
noBuild,
dryRun,
secrets,
sourceMap,
watch,
verbose,
confirm,
json,
allowDeprecated,
public: isPublic,
visibility,
profile,
url,
bypassBreakingChangeDetection,
} as const
@@ -0,0 +1,27 @@
import * as sdk from '@botpress/sdk'
import integrationWithEntityDependency from './bp_modules/integration-with-entity-dependency'
import pluginWithInterfaceDependency from './bp_modules/plugin-with-interface-dependency'
export default new sdk.BotDefinition({})
.addIntegration(integrationWithEntityDependency, {
alias: 'integration-with-entity-dep',
enabled: true,
configuration: {},
})
.addPlugin(pluginWithInterfaceDependency, {
alias: 'plugin-alias',
configuration: {
foo: 'bar',
item: {
id: 'foo',
name: 'Foo',
color: 'blue',
},
},
dependencies: {
'interface-alias': {
integrationAlias: 'integration-with-entity-dep',
integrationInterfaceAlias: Object.keys(integrationWithEntityDependency.definition.interfaces)[0]!,
},
},
})
@@ -0,0 +1,18 @@
{
"name": "cli-fixtures-bot-with-plugin-dependency",
"scripts": {
"check:type": "tsc --noEmit"
},
"private": true,
"dependencies": {
"@botpress/sdk": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.16.4",
"typescript": "^5.6.3"
},
"bpDependencies": {
"integration-with-entity-dependency": "../../integrations/integration-with-entity-dependency",
"plugin-with-interface-dependency": "../../plugins/plugin-with-interface-dependency"
}
}
@@ -0,0 +1,7 @@
import * as bp from '.botpress'
const bot = new bp.Bot({
actions: {},
})
export default bot
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"lib": ["es2022"],
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"allowUnusedLabels": false,
"allowUnreachableCode": false,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noUncheckedIndexedAccess": true,
"noUnusedParameters": true,
"target": "es2017",
"paths": { "*": ["./*"] },
"outDir": "dist",
"checkJs": false,
"incremental": true,
"exactOptionalPropertyTypes": false,
"resolveJsonModule": true,
"noPropertyAccessFromIndexSignature": false,
"noUnusedLocals": false
},
"include": [".botpress/**/*", "src/**/*", "*.ts", "*.json"]
}
@@ -0,0 +1,17 @@
import * as sdk from '@botpress/sdk'
import interfaceWithEntities from './bp_modules/interface-with-entities'
import * as packageJson from './package.json'
export default new sdk.IntegrationDefinition({
name: packageJson.integrationName,
version: '1.0.0',
entities: {
customItem: {
schema: sdk.z.object({
id: sdk.z.string(),
name: sdk.z.string().optional(),
color: sdk.z.string().optional(),
}),
},
},
}).extend(interfaceWithEntities, ({ entities }) => ({ entities: { item: entities.customItem } }))
@@ -0,0 +1,18 @@
{
"name": "cli-fixtures-integration-with-entity-dependency",
"integrationName": "cli-fixtures-integration-with-entity-dependency",
"scripts": {
"check:type": "tsc --noEmit"
},
"private": true,
"dependencies": {
"@botpress/sdk": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.16.4",
"typescript": "^5.6.3"
},
"bpDependencies": {
"interface-with-entities": "../../interfaces/interface-with-entities"
}
}
@@ -0,0 +1,19 @@
import * as bp from '.botpress'
export default new bp.Integration({
register() {
throw new Error('Not implemented')
},
unregister() {
throw new Error('Not implemented')
},
actions: {
manipulateItem() {
throw new Error('Not implemented')
},
},
channels: {},
handler() {
throw new Error('Not implemented')
},
})
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"lib": ["es2022"],
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"allowUnusedLabels": false,
"allowUnreachableCode": false,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noUncheckedIndexedAccess": true,
"noUnusedParameters": true,
"target": "es2017",
"paths": { "*": ["./*"] },
"outDir": "dist",
"checkJs": false,
"incremental": true,
"exactOptionalPropertyTypes": false,
"resolveJsonModule": true,
"noPropertyAccessFromIndexSignature": false,
"noUnusedLocals": false
},
"include": [".botpress/**/*", "src/**/*", "*.ts", "*.json"]
}
@@ -0,0 +1,38 @@
import * as sdk from '@botpress/sdk'
import * as packageJson from './package.json'
export default new sdk.InterfaceDefinition({
name: packageJson.interfaceName,
version: '1.0.0',
entities: {
item: {
schema: sdk.z.object({
id: sdk.z.string(),
name: sdk.z.string().optional(),
}),
},
},
actions: {
manipulateItem: {
input: {
schema: (entities) =>
sdk.z.object({
item: entities.item,
foo: sdk.z.number(),
}),
},
output: {
schema: () => sdk.z.object({}),
},
},
},
events: {
somethingHappened: {
schema: (entities) =>
sdk.z.object({
item: entities.item,
message: sdk.z.string(),
}),
},
},
})
@@ -0,0 +1,15 @@
{
"name": "cli-fixtures-interface-with-entities",
"interfaceName": "cli-fixtures-interface-with-entities",
"scripts": {
"check:type": "tsc --noEmit"
},
"private": true,
"dependencies": {
"@botpress/sdk": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.16.4",
"typescript": "^5.6.3"
}
}
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"lib": ["es2022"],
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"allowUnusedLabels": false,
"allowUnreachableCode": false,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noUncheckedIndexedAccess": true,
"noUnusedParameters": true,
"target": "es2017",
"paths": { "*": ["./*"] },
"outDir": "dist",
"checkJs": false,
"incremental": true,
"exactOptionalPropertyTypes": false,
"resolveJsonModule": true,
"noPropertyAccessFromIndexSignature": false,
"noUnusedLocals": false
},
"include": [".botpress/**/*", "*.ts", "*.json"]
}
@@ -0,0 +1,18 @@
{
"name": "cli-fixtures-plugin-with-interface-dependency",
"pluginName": "cli-fixtures-plugin-with-interface-dependency",
"scripts": {
"check:type": "tsc --noEmit"
},
"private": true,
"dependencies": {
"@botpress/sdk": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.16.4",
"typescript": "^5.6.3"
},
"bpDependencies": {
"interface-with-entities": "../../interfaces/interface-with-entities"
}
}
@@ -0,0 +1,32 @@
import * as sdk from '@botpress/sdk'
import interfaceWithEntities from './bp_modules/interface-with-entities'
import * as packageJson from './package.json'
export default new sdk.PluginDefinition({
name: packageJson.pluginName,
version: '1.0.0',
interfaces: {
'interface-alias': interfaceWithEntities,
},
configuration: {
schema: ({ entities }) =>
sdk.z.object({
item: entities['interface-alias'].item,
foo: sdk.z.literal('bar'),
}),
},
actions: {
doSomething: {
input: {
schema: ({ entities }) =>
sdk.z.object({
item: entities['interface-alias'].item,
bar: sdk.z.number(),
}),
},
output: {
schema: sdk.z.object({}),
},
},
},
})
@@ -0,0 +1,7 @@
import * as bp from '.botpress'
const plugin = new bp.Plugin({
actions: {},
})
export default plugin
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"lib": ["es2022"],
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"allowUnusedLabels": false,
"allowUnreachableCode": false,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noUncheckedIndexedAccess": true,
"noUnusedParameters": true,
"target": "es2017",
"paths": { "*": ["./*"] },
"outDir": "dist",
"checkJs": false,
"incremental": true,
"exactOptionalPropertyTypes": false,
"resolveJsonModule": true,
"noPropertyAccessFromIndexSignature": false,
"noUnusedLocals": false
},
"include": [".botpress/**/*", "src/**/*", "*.ts", "*.json"]
}
+143
View File
@@ -0,0 +1,143 @@
import { Logger } from '@bpinternal/log4bot'
import yargs, { YargsConfig, YargsSchema } from '@bpinternal/yargs-extra'
import * as consts from '../src/consts'
import { requiredBotSecrets } from './tests/bot-secrets'
import { createDeployBot } from './tests/create-deploy-bot'
import { createDeployBotWithDependencies } from './tests/create-deploy-bot-with-dependencies'
import { createDeployIntegration } from './tests/create-deploy-integration'
import { devBot } from './tests/dev-bot'
import { installAllInterfaces } from './tests/install-interfaces'
import {
addIntegration,
addPlugin,
addLocalIntegrationKeepsRelativePath,
addDevIntegrationSkipsBpDependencies,
addDevIntegrationPreservesExistingBpDependency,
reinstallLocalIntegration,
} from './tests/install-package'
import { requiredIntegrationSecrets } from './tests/integration-secrets'
import {
enforceWorkspaceHandleIntegration,
enforceWorkspaceHandlePlugin,
prependWorkspaceHandleIntegration,
prependWorkspaceHandlePlugin,
} from './tests/manage-workspace-handle'
import { removePackage } from './tests/remove-package'
import { Test } from './typings'
import { sleep, TmpDirectory } from './utils'
const tests: Test[] = [
createDeployBot,
createDeployBotWithDependencies,
createDeployIntegration,
devBot,
requiredBotSecrets,
requiredIntegrationSecrets,
prependWorkspaceHandleIntegration,
enforceWorkspaceHandleIntegration,
prependWorkspaceHandlePlugin,
enforceWorkspaceHandlePlugin,
addIntegration,
addPlugin,
addLocalIntegrationKeepsRelativePath,
addDevIntegrationSkipsBpDependencies,
addDevIntegrationPreservesExistingBpDependency,
reinstallLocalIntegration,
installAllInterfaces,
removePackage,
]
const timeout = (ms: number) =>
sleep(ms).then(() => {
throw new Error(`Timeout after ${ms}ms`)
})
const TIMEOUT = 300_000
const configSchema = {
timeout: {
type: 'number',
default: TIMEOUT,
},
verbose: {
type: 'boolean',
default: false,
alias: 'v',
},
filter: {
type: 'string',
},
workspaceId: {
type: 'string',
demandOption: true,
},
workspaceHandle: {
type: 'string',
demandOption: true,
},
token: {
type: 'string',
demandOption: true,
},
apiUrl: {
type: 'string',
default: consts.defaultBotpressApiUrl,
},
tunnelUrl: {
type: 'string',
default: consts.defaultTunnelUrl,
},
sdkPath: {
type: 'string',
description: 'Path to the Botpress SDK to install; Allows using a version not released on NPM yet.',
},
clientPath: {
type: 'string',
description: 'Path to the Botpress Client to install; Allows using a version not released on NPM yet.',
},
} satisfies YargsSchema
const main = async (argv: YargsConfig<typeof configSchema>): Promise<never> => {
const logger = new Logger('e2e', { level: argv.verbose ? 'debug' : 'info' })
const filterRegex = argv.filter ? new RegExp(argv.filter) : null
const filteredTests = tests.filter(({ name }) => (filterRegex ? filterRegex.test(name) : true))
logger.info(`Running ${filteredTests.length} / ${tests.length} tests`)
const dependencies = { '@botpress/sdk': argv.sdkPath, '@botpress/client': argv.clientPath } satisfies Record<
string,
string | undefined
>
for (const { name, handler } of filteredTests) {
const logLine = `### Running test: "${name}" ###`
const logPad = '#'.repeat(logLine.length)
logger.info(logPad)
logger.info(logLine)
logger.info(logPad + '\n')
const loggerNamespace = name.replace(/ /g, '_').replace(/[^a-zA-Z0-9_]/g, '')
const tmpDir = TmpDirectory.create()
try {
const t0 = Date.now()
await Promise.race([
handler({ tmpDir: tmpDir.path, dependencies, logger: logger.sub(loggerNamespace), ...argv }),
timeout(argv.timeout),
])
const t1 = Date.now()
logger.info(`SUCCESS: "${name}" (${t1 - t0}ms)`)
} catch (thrown) {
const err = thrown instanceof Error ? thrown : new Error(`${thrown}`)
logger.attachError(err).error(`FAILURE: "${name}"`)
process.exit(1)
} finally {
tmpDir.cleanup()
}
}
logger.info('All tests passed')
process.exit(0)
}
void yargs.command('$0', 'Run E2E Tests', configSchema, main).parse()
+8
View File
@@ -0,0 +1,8 @@
import * as client from '@botpress/client'
export const config: client.RetryConfig = {
retries: 3,
retryCondition: (err) =>
client.axiosRetry.isNetworkOrIdempotentRequestError(err) || [429, 502].includes(err.response?.status ?? 0),
retryDelay: (retryCount) => retryCount * 1000,
}
+110
View File
@@ -0,0 +1,110 @@
import { Client } from '@botpress/client'
import * as sdk from '@botpress/sdk'
import fs from 'fs'
import pathlib from 'path'
import * as uuid from 'uuid'
import impl from '../../src'
import { ApiBot, fetchAllBots } from '../api'
import defaults from '../defaults'
import * as retry from '../retry'
import { Test } from '../typings'
import * as utils from '../utils'
type SecretDef = NonNullable<sdk.BotDefinitionProps['secrets']>
const fetchBot = async (client: Client, botName: string): Promise<ApiBot | undefined> => {
const bots = await fetchAllBots(client)
return bots.find(({ name }) => name === botName)
}
const appendSecretDefinition = (originalTsContent: string, secrets: SecretDef): string => {
const secretEntries = Object.entries(secrets)
.map(([secretName, secretDef]) => ` ${secretName}: ${JSON.stringify(secretDef)},`)
.join('\n')
return originalTsContent.replace(
'new BotDefinition({})',
`new BotDefinition({\n secrets: {\n${secretEntries}\n },\n})`
)
}
export const requiredBotSecrets: Test = {
name: 'cli should require required bot secrets',
handler: async ({ tmpDir, dependencies, ...creds }) => {
const botpressHomeDir = pathlib.join(tmpDir, '.botpresshome')
const baseDir = pathlib.join(tmpDir, 'bots')
const botName = uuid.v4()
const botDir = pathlib.join(baseDir, botName)
const definitionPath = pathlib.join(botDir, 'bot.definition.ts')
const argv = {
...defaults,
botpressHome: botpressHomeDir,
confirm: true,
...creds,
}
const client = new Client({
apiUrl: creds.apiUrl,
token: creds.token,
workspaceId: creds.workspaceId,
retry: retry.config,
})
await impl
.init({ ...argv, workDir: baseDir, name: botName, type: 'bot', template: 'empty' })
.then(utils.handleExitCode)
const originalDefinition: string = fs.readFileSync(definitionPath, 'utf-8')
const modifiedDefinition = appendSecretDefinition(originalDefinition, {
REQUIRED_SECRET: {},
OPTIONAL_SECRET: { optional: true },
})
fs.writeFileSync(definitionPath, modifiedDefinition, 'utf-8')
await utils.fixBotpressDependencies({ workDir: botDir, target: dependencies })
await utils.npmInstall({ workDir: botDir }).then(utils.handleExitCode)
await impl.build({ ...argv, workDir: botDir }).then(utils.handleExitCode)
await impl.login({ ...argv }).then(utils.handleExitCode)
await impl.bots.create({ ...argv, name: botName, ifNotExists: false }).then(utils.handleExitCode)
const bot = await fetchBot(client, botName)
if (!bot) {
throw new Error(`Bot ${botName} should have been created`)
}
const { exitCode } = await impl.deploy({
...argv,
workDir: botDir,
secrets: ['OPTIONAL_SECRET=lol'],
botId: bot.id,
createNewBot: false,
})
if (exitCode === 0) {
throw new Error('Expected deploy to fail')
}
await impl
.deploy({
...argv,
workDir: botDir,
secrets: ['REQUIRED_SECRET=lol'],
botId: bot.id,
createNewBot: false,
})
.then(utils.handleExitCode)
const { bot: deployedBot } = await client.getBot({ id: bot.id })
if (!deployedBot.secrets.includes('REQUIRED_SECRET')) {
throw new Error(`Bot ${botName} should have secret REQUIRED_SECRET, got: ${deployedBot.secrets.join(', ')}`)
}
// cleanup deployed bot
await impl.bots.delete({ ...argv, botRef: bot.id }).then(utils.handleExitCode)
if (await fetchBot(client, botName)) {
throw new Error(`Bot ${botName} should have been deleted`)
}
},
}
@@ -0,0 +1,330 @@
import { Client } from '@botpress/client'
import fs from 'fs/promises'
import pathlib from 'path'
import * as uuid from 'uuid'
import impl from '../../src'
import { ApiBot, ApiIntegration, ApiInterface, ApiPlugin } from '../api'
import defaults from '../defaults'
import * as retry from '../retry'
import { Test } from '../typings'
import * as utils from '../utils'
type ARGV = typeof defaults & {
botpressHome: string
confirm: true
workspaceId: string
workspaceHandle: string
token: string
apiUrl: string
tunnelUrl: string
sdkPath?: string
clientPath?: string
}
export const createDeployBotWithDependencies: Test = {
name: 'cli should allow creating, building, deploying and managing a bot that has nested dependencies',
handler: async ({ tmpDir, dependencies, logger, ...creds }) => {
if (!_isBotpressWorkspace(creds.workspaceHandle, creds.workspaceId)) {
// Unfortunately, only the botpress workspace can deploy interfaces
logger.info(
`Skipping test because the workspace is not a Botpress workspace (workspaceHandle: ${creds.workspaceHandle}, workspaceId: ${creds.workspaceId})`
)
return
}
const botpressHomeDir = pathlib.join(tmpDir, '.botpresshome')
const fixturesDir = pathlib.join(process.cwd(), 'e2e', 'fixtures')
const interfaceDir = pathlib.join(tmpDir, 'interfaces', 'interface-with-entities')
const integrationDir = pathlib.join(tmpDir, 'integrations', 'integration-with-entity-dependency')
const pluginDir = pathlib.join(tmpDir, 'plugins', 'plugin-with-interface-dependency')
const botDir = pathlib.join(tmpDir, 'bots', 'bot-with-plugin-dependency')
// copy all subfolders from fixturesDir to tmpDir:
await fs.cp(fixturesDir, tmpDir, { recursive: true, force: true })
const argv = {
...defaults,
botpressHome: botpressHomeDir,
confirm: true,
...creds,
} satisfies ARGV
const client = new Client({
apiUrl: creds.apiUrl,
token: creds.token,
workspaceId: creds.workspaceId,
retry: retry.config,
})
await impl.login({ ...argv }).then(utils.handleExitCode)
// Generate names:
const interfaceName = _generateInterfaceName()
const integrationName = _generateIntegrationName()
const pluginName = _generatePluginName()
const botName = _generateBotName()
// Deploy dependencies:
await using _iface = await _deployInterface({ argv, client, dependencies, workDir: interfaceDir, interfaceName })
await using _integration = await _deployIntegration({
argv,
client,
dependencies,
workDir: integrationDir,
integrationName,
})
await using _plugin = await _deployPlugin({ argv, client, dependencies, workDir: pluginDir, pluginName })
// Deploy bot:
await using bot = await _deployBot({ argv, client, dependencies, workDir: botDir, botName })
// Fetch and verify the deployed bot:
const deployedBot = await client.getBot({ id: bot.botId }).then((res) => res.bot)
// TODO: use vitest for our e2e tests and replace this series of if/else
// checks with an expect().toMatchObject() assertion.
// Check that the plugin is installed and enabled:
if (!deployedBot.plugins?.['plugin-alias']?.enabled) {
throw new Error('Expected bot to have plugin "plugin-alias"')
}
// Check that the schemas have been merged correctly:
const action = deployedBot.actions?.['plugin-alias#doSomething']
if (!action?.input?.schema?.properties) {
throw new Error('Expected bot to have action "plugin-alias#doSomething"')
}
const itemEntityProperties = Object.keys(action.input.schema.properties.item.properties)
// Property defined by the interface:
if (!itemEntityProperties.includes('name')) {
throw new Error('Expected "plugin-alias#doSomething" action input to have item.name property')
}
// Property defined by the integration:
if (!itemEntityProperties.includes('color')) {
throw new Error('Expected "plugin-alias#doSomething" action input to have item.color property')
}
},
}
const _isBotpressWorkspace = (workspaceHandle: string, workspaceId: string): boolean =>
workspaceHandle === 'botpress' ||
[
'6a76fa10-e150-4ff6-8f59-a300feec06c1',
'95de33eb-1551-4af9-9088-e5dcb02efd09',
'11111111-1111-1111-aaaa-111111111111',
].includes(workspaceId)
const _deployInterface = async ({
argv,
client,
workDir,
interfaceName,
dependencies,
}: {
argv: ARGV
client: Client
workDir: string
interfaceName: string
dependencies: Record<string, string | undefined>
}) => {
await _editPackageJson({ workDir, interfaceName })
await _installAndBuild({ argv, workDir, dependencies })
await impl
.deploy({ ...argv, workDir, createNewBot: undefined, botId: undefined, visibility: 'public' })
.then(utils.handleExitCode)
return {
[Symbol.asyncDispose]: () => _deleteInterfaceIfExists(client, interfaceName),
}
}
const _deployIntegration = async ({
argv,
client,
workDir,
integrationName,
dependencies,
}: {
argv: ARGV
client: Client
workDir: string
integrationName: string
dependencies: Record<string, string | undefined>
}) => {
await _editPackageJson({ workDir, integrationName })
await _installAndBuild({ argv, workDir, dependencies })
await impl.deploy({ ...argv, workDir, createNewBot: undefined, botId: undefined }).then(utils.handleExitCode)
return {
[Symbol.asyncDispose]: () => _deleteIntegrationIfExists(client, integrationName),
}
}
const _deployPlugin = async ({
argv,
client,
workDir,
pluginName,
dependencies,
}: {
argv: ARGV
client: Client
workDir: string
pluginName: string
dependencies: Record<string, string | undefined>
}) => {
await _editPackageJson({ workDir, pluginName })
await _installAndBuild({ argv, workDir, dependencies })
await impl.deploy({ ...argv, workDir, createNewBot: undefined, botId: undefined }).then(utils.handleExitCode)
return {
[Symbol.asyncDispose]: () => _deletePluginIfExists(client, pluginName),
}
}
const _deployBot = async ({
argv,
client,
workDir,
botName,
dependencies,
}: {
argv: ARGV
client: Client
workDir: string
botName: string
dependencies: Record<string, string | undefined>
}) => {
await _editPackageJson({ workDir, botName })
await _installAndBuild({ argv, workDir, dependencies })
await impl.bots.create({ ...argv, name: botName, ifNotExists: false }).then(utils.handleExitCode)
const bot = await _fetchBotByName(client, botName)
if (!bot) {
throw new Error(`Bot ${botName} should have been created`)
}
await impl.deploy({ ...argv, workDir, createNewBot: false, botId: bot.id }).then(utils.handleExitCode)
return {
botId: bot.id,
[Symbol.asyncDispose]: () => _deleteBotIfExists(client, botName),
}
}
const _installAndBuild = async ({
argv,
workDir,
dependencies,
}: {
argv: ARGV
workDir: string
dependencies: Record<string, string | undefined>
}) => {
await utils.fixBotpressDependencies({ workDir, target: dependencies })
await utils.npmInstall({ workDir }).then(utils.handleExitCode)
const prevDir = process.cwd()
process.chdir(workDir)
await impl
.add({
...argv,
installPath: workDir,
packageRef: undefined,
useDev: false,
alias: undefined,
})
.then(utils.handleExitCode)
process.chdir(prevDir)
await impl.build({ ...argv, workDir }).then(utils.handleExitCode)
}
const _fetchBotByName = (client: Client, botName: string): Promise<ApiBot | undefined> =>
client.list
.bots({})
.collect()
.then((bots) => bots.find(({ name }) => name === botName))
const _fetchIntegrationByName = (client: Client, integrationName: string): Promise<ApiIntegration | undefined> =>
client.list
.integrations({})
.collect()
.then((integrations) => integrations.find(({ name }) => name === integrationName))
const _fetchInterfaceByName = (client: Client, interfaceName: string): Promise<ApiInterface | undefined> =>
client.list
.publicInterfaces({})
.collect()
.then((interfaces) => interfaces.find(({ name }) => name === interfaceName))
const _fetchPluginByName = (client: Client, pluginName: string): Promise<ApiPlugin | undefined> =>
client.list
.plugins({})
.collect()
.then((plugins) => plugins.find(({ name }) => name === pluginName))
const _editPackageJson = async (payload: { workDir: string; [k: string]: string }) => {
const { workDir, ...modifications } = payload
const packageJsonPath = pathlib.join(workDir, 'package.json')
const originalPackageJson: Record<string, unknown> = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8'))
const newPackageJson = {
...originalPackageJson,
...modifications,
}
await fs.writeFile(packageJsonPath, JSON.stringify(newPackageJson, null, 2))
}
const _generateInterfaceName = () => `iface-${uuid.v4()}` as const
const _generateIntegrationName = () => `integration-${uuid.v4()}` as const
const _generatePluginName = () => `plugin-${uuid.v4()}` as const
const _generateBotName = () => `bot-${uuid.v4()}` as const
const _deleteBotIfExists = async (client: Client, botName: string): Promise<void> => {
console.debug(`Deleting bot: ${botName}`)
const bot = await _fetchBotByName(client, botName)
if (!bot) {
return
}
await _swallowErrors(client.deleteBot({ id: bot.id })).then(() => utils.sleep(10_000))
}
const _deleteIntegrationIfExists = async (client: Client, integrationName: string): Promise<void> => {
console.debug(`Deleting integration: ${integrationName}`)
const integration = await _fetchIntegrationByName(client, integrationName)
if (!integration) {
return
}
await _swallowErrors(client.deleteIntegration({ id: integration.id }))
}
const _deleteInterfaceIfExists = async (client: Client, interfaceName: string): Promise<void> => {
console.debug(`Deleting interface: ${interfaceName}`)
const iface = await _fetchInterfaceByName(client, interfaceName)
if (!iface) {
return
}
await _swallowErrors(client.deleteInterface({ id: iface.id }))
}
const _deletePluginIfExists = async (client: Client, pluginName: string): Promise<void> => {
console.debug(`Deleting plugin: ${pluginName}`)
const plugin = await _fetchPluginByName(client, pluginName)
if (!plugin) {
return
}
await _swallowErrors(client.deletePlugin({ id: plugin.id }))
}
const _swallowErrors = async (promise: Promise<unknown>) => {
try {
await promise
} catch (thrown: unknown) {
// Swallow errors to allow cleanup to proceed:
console.warn('Error during cleanup:', thrown)
}
}
@@ -0,0 +1,59 @@
import { Client } from '@botpress/client'
import pathlib from 'path'
import * as uuid from 'uuid'
import impl from '../../src'
import { ApiBot, fetchAllBots } from '../api'
import defaults from '../defaults'
import * as retry from '../retry'
import { Test } from '../typings'
import * as utils from '../utils'
const fetchBot = async (client: Client, botName: string): Promise<ApiBot | undefined> => {
const bots = await fetchAllBots(client)
return bots.find(({ name }) => name === botName)
}
export const createDeployBot: Test = {
name: 'cli should allow creating, building, deploying and managing a bot',
handler: async ({ tmpDir, dependencies, ...creds }) => {
const botpressHomeDir = pathlib.join(tmpDir, '.botpresshome')
const baseDir = pathlib.join(tmpDir, 'bots')
const botName = uuid.v4()
const botDir = pathlib.join(baseDir, botName)
const argv = {
...defaults,
botpressHome: botpressHomeDir,
confirm: true,
...creds,
}
const client = new Client({
apiUrl: creds.apiUrl,
token: creds.token,
workspaceId: creds.workspaceId,
retry: retry.config,
})
await impl
.init({ ...argv, workDir: baseDir, name: botName, type: 'bot', template: 'empty' })
.then(utils.handleExitCode)
await utils.fixBotpressDependencies({ workDir: botDir, target: dependencies })
await utils.npmInstall({ workDir: botDir }).then(utils.handleExitCode)
await impl.build({ ...argv, workDir: botDir }).then(utils.handleExitCode)
await impl.login({ ...argv }).then(utils.handleExitCode)
await impl.bots.create({ ...argv, name: botName, ifNotExists: false }).then(utils.handleExitCode)
const bot = await fetchBot(client, botName)
if (!bot) {
throw new Error(`Bot ${botName} should have been created`)
}
await impl.deploy({ ...argv, workDir: botDir, createNewBot: false, botId: bot.id }).then(utils.handleExitCode)
await impl.bots.delete({ ...argv, botRef: bot.id }).then(utils.handleExitCode)
if (await fetchBot(client, botName)) {
throw new Error(`Bot ${botName} should have been deleted`)
}
},
}
@@ -0,0 +1,66 @@
import { Client } from '@botpress/client'
import pathlib from 'path'
import * as uuid from 'uuid'
import impl from '../../src'
import { ApiIntegration, fetchAllIntegrations } from '../api'
import defaults from '../defaults'
import * as retry from '../retry'
import { Test } from '../typings'
import * as utils from '../utils'
const fetchIntegration = async (client: Client, integrationName: string): Promise<ApiIntegration | undefined> => {
const integrations = await fetchAllIntegrations(client)
return integrations.find(({ name }) => name === integrationName)
}
export const createDeployIntegration: Test = {
name: 'cli should allow creating, building, deploying and managing an integration',
handler: async ({ tmpDir, dependencies, workspaceHandle, logger, ...creds }) => {
const botpressHomeDir = pathlib.join(tmpDir, '.botpresshome')
const baseDir = pathlib.join(tmpDir, 'integrations')
const integrationSuffix = uuid.v4().replace(/-/g, '')
const name = `myintegration${integrationSuffix}`
const integrationName = `${workspaceHandle}/${name}`
const integrationDir = pathlib.join(baseDir, name)
const argv = {
...defaults,
botpressHome: botpressHomeDir,
confirm: true,
...creds,
}
const client = new Client({
apiUrl: creds.apiUrl,
token: creds.token,
workspaceId: creds.workspaceId,
retry: retry.config,
})
await impl
.init({ ...argv, workDir: baseDir, name: integrationName, type: 'integration', template: 'empty' })
.then(utils.handleExitCode)
await utils.fixBotpressDependencies({ workDir: integrationDir, target: dependencies })
await utils.npmInstall({ workDir: integrationDir }).then(utils.handleExitCode)
await impl.build({ ...argv, workDir: integrationDir }).then(utils.handleExitCode)
await impl.login({ ...argv }).then(utils.handleExitCode)
await impl
.deploy({ ...argv, createNewBot: undefined, botId: undefined, workDir: integrationDir })
.then(utils.handleExitCode)
logger.debug(`Fetching integration "${integrationName}"`)
const integration = await fetchIntegration(client, integrationName)
if (!integration) {
throw new Error(`Integration ${integrationName} should have been created`)
}
logger.debug(`Deleting integration "${integrationName}"`)
await impl.integrations.delete({ ...argv, integrationRef: integration.id }).then(utils.handleExitCode)
if (await fetchIntegration(client, integrationName)) {
throw new Error(`Integration ${integrationName} should have been deleted`)
}
},
}
+117
View File
@@ -0,0 +1,117 @@
import { Client, isApiError } from '@botpress/client'
import axios from 'axios'
import findProcess from 'find-process'
import fslib from 'fs'
import pathlib from 'path'
import * as uuid from 'uuid'
import impl from '../../src'
import defaults from '../defaults'
import * as retry from '../retry'
import { Test } from '../typings'
import * as utils from '../utils'
const handleExitCode = ({ exitCode }: { exitCode: number }) => {
if (exitCode !== 0) {
throw new Error(`Command exited with code ${exitCode}`)
}
}
const fetchDevBotById = async (client: Client, id: string) => {
const { bot } = await client.getBot({ id }).catch((err) => {
if (isApiError(err) && err.type === 'ResourceNotFound') {
return { bot: undefined }
}
throw err
})
return bot
}
const readBotCache = async (
botDir: string
): Promise<{
botId?: string
tunnelId?: string
devId?: string
}> => {
const botCache = pathlib.join(botDir, '.botpress', 'project.cache.json')
if (!fslib.existsSync(botCache)) {
return {}
}
const cacheContent: string = await fslib.promises.readFile(botCache, 'utf-8')
try {
return JSON.parse(cacheContent)
} catch {
return {}
}
}
const PORT = 8075
export const devBot: Test = {
name: 'cli should allow creating and running a bot locally',
handler: async ({ tmpDir, tunnelUrl, dependencies, ...creds }) => {
const botpressHomeDir = pathlib.join(tmpDir, '.botpresshome')
const baseDir = pathlib.join(tmpDir, 'bots')
const botName = uuid.v4()
const tunnelId = uuid.v4()
const botDir = pathlib.join(baseDir, botName)
const argv = {
...defaults,
botpressHome: botpressHomeDir,
confirm: true,
...creds,
}
const client = new Client({
apiUrl: creds.apiUrl,
token: creds.token,
workspaceId: creds.workspaceId,
retry: retry.config,
})
await impl.init({ ...argv, workDir: baseDir, name: botName, type: 'bot', template: 'empty' }).then(handleExitCode)
await utils.fixBotpressDependencies({ workDir: botDir, target: dependencies })
await utils.npmInstall({ workDir: botDir }).then(handleExitCode)
await impl.login({ ...argv }).then(handleExitCode)
const cmdPromise = impl
.dev({ ...argv, workDir: botDir, port: PORT, tunnelUrl, tunnelId, noSecretCaching: true })
.then(handleExitCode)
await utils.sleep(5000)
const allProcess = await findProcess('port', PORT)
const [botProcess] = allProcess
if (allProcess.length > 1) {
throw new Error(`Expected to find only one process listening on port ${PORT}`)
}
if (!botProcess) {
throw new Error(`Expected to find a process listening on port ${PORT}`)
}
const botCache = await readBotCache(botDir)
const { devId: botId } = botCache
if (!botId) {
throw new Error('Expected devId to be set in project cache')
}
const resp = await axios.get(`http://localhost:${PORT}/health`)
if (resp.status !== 200) {
throw new Error(`Expected health endpoint to return 200, got ${resp.status}`)
}
process.kill(botProcess.pid)
await cmdPromise
const apiBot = await fetchDevBotById(client, botId)
if (!apiBot) {
throw new Error(`Dev Bot ${botId} should have been created in the backend`)
}
await impl.bots.delete({ ...argv, botRef: apiBot.id }).then(utils.handleExitCode)
if (await fetchDevBotById(client, botId)) {
throw new Error(`Dev bot ${botId} should have been deleted`)
}
},
}
@@ -0,0 +1,49 @@
import pathlib from 'path'
import impl from '../../src'
import defaults from '../defaults'
import { Test } from '../typings'
import * as utils from '../utils'
export const installAllInterfaces: Test = {
name: 'cli should allow installing public interfaces',
handler: async ({ tmpDir, logger, ...creds }) => {
const botpressHomeDir = pathlib.join(tmpDir, '.botpresshome')
const baseDir = pathlib.join(tmpDir, 'interfaces')
const argv = {
...defaults,
botpressHome: botpressHomeDir,
confirm: true,
...creds,
}
await impl.login({ ...argv }).then(utils.handleExitCode)
const interfaces: string[] = [
'creatable',
'deletable',
'hitl',
'listable',
'llm',
'readable',
'speech-to-text',
'text-to-image',
'typing-indicator',
'updatable',
]
for (const iface of interfaces) {
logger.info(`Installing interface: ${iface}`)
await impl
.add({
...argv,
packageRef: iface,
installPath: baseDir,
useDev: false,
alias: undefined,
})
.then(utils.handleExitCode)
// TODO: also run a type check on the installed interface
}
},
}
+508
View File
@@ -0,0 +1,508 @@
import * as client from '@botpress/client'
import * as sdk from '@botpress/sdk'
import * as fslib from 'fs'
import * as pathlib from 'path'
import * as uuid from 'uuid'
import impl from '../../src'
import * as apiUtils from '../../src/api'
import * as consts from '../../src/consts'
import * as cliUtils from '../../src/utils'
import { ApiBot, fetchAllBots } from '../api'
import defaults from '../defaults'
import * as retry from '../retry'
import { Test, TestProps } from '../typings'
import * as utils from '../utils'
const issueSchema = sdk.z.object({
id: sdk.z.string(),
priority: sdk.z.enum(['high', 'medium', 'low']),
title: sdk.z.string(),
body: sdk.z.string(),
})
const INTEGRATION = {
version: '0.0.1',
title: 'An Integration',
description: 'An integration',
user: { tags: { id: { title: 'ID', description: 'The user ID' } } },
configuration: { schema: sdk.z.object({}), identifier: { required: true, linkTemplateScript: '' } },
configurations: {
token: {
title: 'API Token',
description: 'The token to authenticate with',
schema: sdk.z.object({ token: sdk.z.string() }),
},
},
actions: {
getIssue: {
input: { schema: sdk.z.object({ id: sdk.z.string() }) },
output: { schema: issueSchema },
},
},
events: {
issueCreated: {
title: 'Issue Created',
description: 'An issue was created',
schema: issueSchema,
},
},
channels: {
issueComment: {
title: 'Issue Comment',
description: 'Comment on an issue',
messages: { text: sdk.messages.defaults.text },
conversation: { tags: { id: { title: 'ID', description: 'The issue ID' } } },
message: { tags: { id: { title: 'ID', description: 'The issue comment ID' } } },
},
},
entities: {
issue: {
title: 'Issue',
description: 'An issue',
schema: issueSchema,
},
},
states: {
lastCreatedIssue: {
type: 'integration',
schema: issueSchema,
},
},
identifier: {
extractScript: '',
},
} satisfies Omit<sdk.IntegrationDefinitionProps, 'name'>
const getHomeDir = (props: { tmpDir: string }) => pathlib.join(props.tmpDir, '.botpresshome')
const initBot = async (props: TestProps, definitionFile: string) => {
const { tmpDir, dependencies, ...creds } = props
const argv = {
...defaults,
botpressHome: getHomeDir(props),
confirm: true,
...creds,
}
const botName = uuid.v4().replace(/-/g, '')
const botDir = pathlib.join(tmpDir, botName)
await impl
.init({ ...argv, workDir: tmpDir, name: botName, type: 'bot', template: 'empty' })
.then(utils.handleExitCode)
await utils.fixBotpressDependencies({ workDir: botDir, target: dependencies })
await utils.npmInstall({ workDir: botDir }).then(utils.handleExitCode)
await fslib.promises.writeFile(pathlib.join(botDir, 'bot.definition.ts'), definitionFile)
return { botDir }
}
// TODO: add an equivalent test with an interface once interfaces can be created by any workspace
export const addIntegration: Test = {
name: 'cli should allow installing an integration',
handler: async (props) => {
const { tmpDir, workspaceHandle, logger, ...creds } = props
const argv = {
...defaults,
botpressHome: getHomeDir({ tmpDir }),
confirm: true,
...creds,
}
const bpClient = new client.Client({
apiUrl: creds.apiUrl,
token: creds.token,
workspaceId: creds.workspaceId,
retry: retry.config,
})
const integrationSuffix = uuid.v4().replace(/-/g, '')
const name = `myintegration${integrationSuffix}`
const integrationName = `${workspaceHandle}/${name}`
const createIntegrationBody = await apiUtils.prepareCreateIntegrationBody(
new sdk.IntegrationDefinition({
...INTEGRATION,
name: integrationName,
})
)
const { integration } = await bpClient.createIntegration({
...createIntegrationBody,
dev: true, // this way we ensure the integration will eventually be janitored if the test fails
url: creds.apiUrl,
})
try {
logger.info('Initializing bot')
const { botDir } = await initBot(
props,
[
'import * as sdk from "@botpress/sdk"',
`import anIntegration from "./bp_modules/${workspaceHandle}-${name}"`,
'export default new sdk.BotDefinition({}).addIntegration(anIntegration, {',
' enabled: true,',
' configurationType: null,',
' configuration: {},',
'})',
].join('\n')
)
logger.info('Logging in')
await impl.login(argv).then(utils.handleExitCode)
logger.info('Installing integration')
await impl
.add({
...argv,
installPath: botDir,
packageRef: integration.id,
useDev: false,
alias: undefined,
})
.then(utils.handleExitCode)
logger.info('Building bot')
await impl.build({ ...argv, workDir: botDir }).then(utils.handleExitCode)
await utils.tscCheck({ workDir: botDir }).then(utils.handleExitCode)
} finally {
await impl.integrations
.delete({
...argv,
integrationRef: integration.id,
})
.catch(() => {
logger.warn(`Failed to delete integration ${integration.id}`) // this is not the purpose of the test
})
}
},
}
export const addPlugin: Test = {
name: 'cli should allow installing a plugin',
handler: async (props) => {
const { tmpDir, workspaceHandle, logger, ...creds } = props
const argv = {
...defaults,
botpressHome: getHomeDir({ tmpDir }),
confirm: true,
...creds,
}
const bpClient = new client.Client({
apiUrl: creds.apiUrl,
token: creds.token,
workspaceId: creds.workspaceId,
retry: retry.config,
})
const pluginSuffix = uuid.v4().replace(/-/g, '')
const name = `myplugin${pluginSuffix}`
const pluginName = `${workspaceHandle}/${name}`
const pluginVersion = '0.1.0'
const pluginAlias = `alias-${uuid.v4().replace(/-/g, '')}`
const botName = uuid.v4()
const createPluginBody = await apiUtils.prepareCreatePluginBody(
new sdk.PluginDefinition({
name: pluginName,
version: pluginVersion,
})
)
const { plugin } = await bpClient.createPlugin({
...createPluginBody,
code: { browser: 'export default {}', node: 'export default {}' },
})
let bot: ApiBot | undefined
try {
logger.info('Initializing bot')
const { botDir } = await initBot(
props,
[
'import * as sdk from "@botpress/sdk"',
`import aPlugin from "./bp_modules/${workspaceHandle}-${name}"`,
'export default new sdk.BotDefinition({}).addPlugin(aPlugin, {',
` alias: '${pluginAlias}',`,
' configuration: {},',
' dependencies: {},',
'})',
].join('\n')
)
logger.info('Logging in')
await impl.login(argv).then(utils.handleExitCode)
logger.info('Installing plugin')
await impl
.add({
...argv,
installPath: botDir,
packageRef: plugin.id,
useDev: false,
alias: undefined,
})
.then(utils.handleExitCode)
logger.info('Building bot')
await impl.build({ ...argv, workDir: botDir }).then(utils.handleExitCode)
await utils.tscCheck({ workDir: botDir }).then(utils.handleExitCode)
logger.info('Deploying bot')
await impl.bots.create({ ...argv, name: botName, ifNotExists: false }).then(utils.handleExitCode)
bot = await fetchBot(bpClient, botName)
if (!bot) {
throw new Error(`Bot ${botName} should have been created`)
}
await impl.deploy({ ...argv, workDir: botDir, createNewBot: false, botId: bot.id }).then(utils.handleExitCode)
logger.info('Checking if plugin is installed')
const plugins = await bpClient.getBot({ id: bot.id }).then(({ bot }) => bot.plugins)
const isPluginInstalled = Object.entries(plugins).some(
([alias, instance]) => alias === pluginAlias && instance.id === plugin.id
)
if (!isPluginInstalled) {
throw new Error(`Plugin ${plugin.id} should have been installed in bot ${bot.id}`)
}
} finally {
if (bot) {
await impl.bots
.delete({
...argv,
botRef: bot.id,
})
.catch(() => {
logger.warn(`Failed to delete bot ${bot!.id}`) // this is not the purpose of the test
})
}
await impl.plugins
.delete({
...argv,
pluginRef: plugin.id,
})
.catch(() => {
logger.warn(`Failed to delete plugin ${plugin.id}`) // this is not the purpose of the test
})
}
},
}
const fetchBot = async (client: client.Client, botName: string): Promise<ApiBot | undefined> =>
await fetchAllBots(client).then((bots) => bots.find(({ name }) => name === botName))
const initIntegration = async (props: TestProps, integrationName: string) => {
const { tmpDir, dependencies, workspaceHandle, ...creds } = props
const argv = {
...defaults,
botpressHome: getHomeDir({ tmpDir }),
confirm: true,
...creds,
}
const integrationDir = pathlib.join(tmpDir, integrationName)
await impl
.init({
...argv,
workDir: tmpDir,
name: `${workspaceHandle}/${integrationName}`,
type: 'integration',
template: 'empty',
})
.then(utils.handleExitCode)
await utils.fixBotpressDependencies({ workDir: integrationDir, target: dependencies })
await utils.npmInstall({ workDir: integrationDir }).then(utils.handleExitCode)
return { integrationDir }
}
export const addLocalIntegrationKeepsRelativePath: Test = {
name: 'cli should store the original relative path in bpDependencies when adding a local integration',
handler: async (props) => {
const { tmpDir, logger, workspaceHandle, ...creds } = props
const argv = {
...defaults,
botpressHome: getHomeDir({ tmpDir }),
confirm: true,
workspaceHandle,
...creds,
}
const integrationName = `myintegration${utils.getUUID()}`
const fullIntegrationName = `${workspaceHandle}/${integrationName}`
const { integrationDir } = await initIntegration(props, integrationName)
logger.info('Initializing bot')
const { botDir } = await initBot(
props,
['import * as sdk from "@botpress/sdk"', 'export default new sdk.BotDefinition({})'].join('\n')
)
// The stored path should be relative to installPath (botDir), not process.cwd()
// Use integrationDir (absolute) as packageRef to avoid cross-drive issues on Windows
const rel = pathlib.relative(botDir, integrationDir).split(pathlib.sep).join('/')
const expectedStoredPath = rel.startsWith('.') ? rel : `./${rel}`
logger.info('Adding local integration via relative path')
await impl
.add({ ...argv, installPath: botDir, packageRef: integrationDir, useDev: false, alias: undefined })
.then(utils.handleExitCode)
const pkgJson = JSON.parse(fslib.readFileSync(pathlib.join(botDir, 'package.json'), 'utf8'))
const bpDeps = pkgJson.bpDependencies as Record<string, string> | undefined
if (!bpDeps) {
throw new Error('Expected bpDependencies to be set in package.json after bp add')
}
const storedPath = bpDeps[fullIntegrationName]
if (storedPath !== expectedStoredPath) {
throw new Error(`Expected bpDependencies to store "${expectedStoredPath}" but got "${storedPath}"`)
}
},
}
export const addDevIntegrationSkipsBpDependencies: Test = {
name: 'cli should not modify bpDependencies when adding a local integration with --use-dev',
handler: async (props) => {
const { tmpDir, logger, workspaceHandle, ...creds } = props
const argv = {
...defaults,
botpressHome: getHomeDir({ tmpDir }),
confirm: true,
workspaceHandle,
...creds,
}
const integrationName = `myintegration${utils.getUUID()}`
const fullIntegrationName = `${workspaceHandle}/${integrationName}`
const { integrationDir } = await initIntegration(props, integrationName)
// Simulate a dev integration by writing a devId to the project cache
const cacheDir = pathlib.join(integrationDir, '.botpress')
fslib.mkdirSync(cacheDir, { recursive: true })
fslib.writeFileSync(
pathlib.join(cacheDir, 'project.cache.json'),
JSON.stringify({ devId: 'fake-dev-integration-id' })
)
logger.info('Initializing bot')
const { botDir } = await initBot(
props,
['import * as sdk from "@botpress/sdk"', 'export default new sdk.BotDefinition({})'].join('\n')
)
logger.info('Adding dev integration')
await impl
.add({ ...argv, installPath: botDir, packageRef: integrationDir, useDev: true, alias: undefined })
.then(utils.handleExitCode)
const pkgJson = JSON.parse(fslib.readFileSync(pathlib.join(botDir, 'package.json'), 'utf8'))
const bpDeps = pkgJson.bpDependencies as Record<string, string> | undefined
if (bpDeps?.[fullIntegrationName] !== undefined) {
throw new Error(
`Expected "${fullIntegrationName}" to NOT be in bpDependencies when using --use-dev, but got: ${JSON.stringify(bpDeps)}`
)
}
},
}
export const addDevIntegrationPreservesExistingBpDependency: Test = {
name: 'cli should not overwrite existing bpDependencies entry when re-adding as dev integration',
handler: async (props) => {
const { tmpDir, logger, workspaceHandle, ...creds } = props
const argv = {
...defaults,
botpressHome: getHomeDir({ tmpDir }),
confirm: true,
workspaceHandle,
...creds,
}
const integrationName = `myintegration${utils.getUUID()}`
const fullIntegrationName = `${workspaceHandle}/${integrationName}`
const { integrationDir } = await initIntegration(props, integrationName)
logger.info('Initializing bot')
const { botDir } = await initBot(
props,
['import * as sdk from "@botpress/sdk"', 'export default new sdk.BotDefinition({})'].join('\n')
)
logger.info('Adding local integration (no --use-dev) to populate bpDependencies')
await impl
.add({ ...argv, installPath: botDir, packageRef: integrationDir, useDev: false, alias: undefined })
.then(utils.handleExitCode)
const pkgJsonAfterFirst = JSON.parse(fslib.readFileSync(pathlib.join(botDir, 'package.json'), 'utf8'))
const storedRelativePath = (pkgJsonAfterFirst.bpDependencies as Record<string, string> | undefined)?.[
fullIntegrationName
]
if (!storedRelativePath) {
throw new Error('Expected bpDependencies to contain an entry after initial bp add')
}
// Simulate the integration being run with bp dev (devId present in cache)
const cacheDir = pathlib.join(integrationDir, '.botpress')
fslib.mkdirSync(cacheDir, { recursive: true })
fslib.writeFileSync(
pathlib.join(cacheDir, 'project.cache.json'),
JSON.stringify({ devId: 'fake-dev-integration-id' })
)
logger.info('Re-adding same integration with --use-dev')
await impl
.add({ ...argv, installPath: botDir, packageRef: integrationDir, useDev: true, alias: undefined })
.then(utils.handleExitCode)
const pkgJsonAfterDev = JSON.parse(fslib.readFileSync(pathlib.join(botDir, 'package.json'), 'utf8'))
const bpDepsAfterDev = pkgJsonAfterDev.bpDependencies as Record<string, string> | undefined
if (bpDepsAfterDev?.[fullIntegrationName] !== storedRelativePath) {
throw new Error(
`Expected bpDependencies["${fullIntegrationName}"] to remain "${storedRelativePath}" after --use-dev add, but got: ${JSON.stringify(bpDepsAfterDev?.[fullIntegrationName])}`
)
}
},
}
export const reinstallLocalIntegration: Test = {
name: 'cli should reinstall local integration from bpDependencies regardless of cwd',
handler: async (props) => {
const { tmpDir, logger, workspaceHandle, ...creds } = props
const argv = {
...defaults,
botpressHome: getHomeDir({ tmpDir }),
confirm: true,
...creds,
}
const integrationName = `myintegration${utils.getUUID()}`
const fullIntegrationName = `${workspaceHandle}/${integrationName}`
const { integrationDir } = await initIntegration(props, integrationName)
logger.info('Initializing bot')
const { botDir } = await initBot(
props,
['import * as sdk from "@botpress/sdk"', 'export default new sdk.BotDefinition({})'].join('\n')
)
logger.info('Adding local integration')
await impl
.add({ ...argv, installPath: botDir, packageRef: integrationDir, useDev: false, alias: undefined })
.then(utils.handleExitCode)
const moduleDir = pathlib.join(botDir, consts.installDirName, cliUtils.casing.to.kebabCase(fullIntegrationName))
if (!fslib.existsSync(moduleDir)) {
throw new Error(`Expected bp_modules to contain "${fullIntegrationName}" after bp add`)
}
logger.info('Deleting bp_modules')
fslib.rmSync(pathlib.join(botDir, 'bp_modules'), { recursive: true, force: true })
logger.info('Reinstalling from bpDependencies')
await impl
.add({ ...argv, installPath: botDir, packageRef: undefined, useDev: false, alias: undefined })
.then(utils.handleExitCode)
if (!fslib.existsSync(moduleDir)) {
throw new Error(`Expected bp_modules to contain "${fullIntegrationName}" after reinstall`)
}
},
}
+37
View File
@@ -0,0 +1,37 @@
import pathlib from 'path'
import impl from '../../src'
import defaults from '../defaults'
import { Test } from '../typings'
import * as utils from '../utils'
export const installAllPlugins: Test = {
name: 'cli should allow installing public plugins',
handler: async ({ tmpDir, logger, ...creds }) => {
const botpressHomeDir = pathlib.join(tmpDir, '.botpresshome')
const baseDir = pathlib.join(tmpDir, 'plugins')
const argv = {
...defaults,
botpressHome: botpressHomeDir,
confirm: true,
...creds,
}
await impl.login({ ...argv }).then(utils.handleExitCode)
const plugins: string[] = ['hitl', 'file-synchronizer']
for (const iface of plugins) {
logger.info(`Installing plugin: ${iface}`)
await impl
.add({
...argv,
packageRef: iface,
installPath: baseDir,
useDev: false,
alias: undefined,
})
.then(utils.handleExitCode)
}
},
}
@@ -0,0 +1,115 @@
import { Client } from '@botpress/client'
import * as sdk from '@botpress/sdk'
import fs from 'fs'
import pathlib from 'path'
import * as uuid from 'uuid'
import impl from '../../src'
import { fetchAllIntegrations, ApiIntegration } from '../api'
import defaults from '../defaults'
import * as retry from '../retry'
import { Test } from '../typings'
import * as utils from '../utils'
type SecretDef = NonNullable<sdk.IntegrationDefinitionProps['secrets']>
const fetchIntegration = async (client: Client, integrationName: string): Promise<ApiIntegration | undefined> => {
const integrations = await fetchAllIntegrations(client)
return integrations.find(({ name }) => name === integrationName)
}
const appendSecretDefinition = (originalTsContent: string, secrets: SecretDef): string => {
const regex = /( *)version: (['"].*['"]),/
const replacement = [
'version: $2,',
'secrets: {',
...Object.entries(secrets).map(([secretName, secretDef]) => ` ${secretName}: ${JSON.stringify(secretDef)},`),
'},',
]
.map((s) => `$1${s}`) // for indentation
.join('\n')
const modifiedTsContent = originalTsContent.replace(regex, replacement)
return modifiedTsContent
}
export const requiredIntegrationSecrets: Test = {
name: 'cli should require required integration secrets',
handler: async ({ tmpDir, workspaceHandle, dependencies, ...creds }) => {
const botpressHomeDir = pathlib.join(tmpDir, '.botpresshome')
const baseDir = pathlib.join(tmpDir, 'integrations')
const integrationSuffix = uuid.v4().replace(/-/g, '')
const name = `myintegration${integrationSuffix}`
const integrationName = `${workspaceHandle}/${name}`
const integrationDir = pathlib.join(baseDir, name)
const definitionPath = pathlib.join(integrationDir, 'integration.definition.ts')
const argv = {
...defaults,
botpressHome: botpressHomeDir,
confirm: true,
...creds,
}
const client = new Client({
apiUrl: creds.apiUrl,
token: creds.token,
workspaceId: creds.workspaceId,
retry: retry.config,
})
await impl
.init({ ...argv, workDir: baseDir, name: integrationName, type: 'integration', template: 'empty' })
.then(utils.handleExitCode)
const originalDefinition: string = fs.readFileSync(definitionPath, 'utf-8')
const modifiedDefinition = appendSecretDefinition(originalDefinition, {
REQUIRED_SECRET: {},
OPTIONAL_SECRET: { optional: true },
})
fs.writeFileSync(definitionPath, modifiedDefinition, 'utf-8')
await utils.fixBotpressDependencies({ workDir: integrationDir, target: dependencies })
await utils.npmInstall({ workDir: integrationDir }).then(utils.handleExitCode)
await impl.build({ ...argv, workDir: integrationDir }).then(utils.handleExitCode)
await impl.login({ ...argv }).then(utils.handleExitCode)
const { exitCode } = await impl.deploy({
...argv,
workDir: integrationDir,
secrets: ['OPTIONAL_SECRET=lol'],
botId: undefined,
createNewBot: undefined,
})
if (exitCode === 0) {
throw new Error('Expected deploy to fail')
}
await impl
.deploy({
...argv,
workDir: integrationDir,
secrets: ['REQUIRED_SECRET=lol'],
botId: undefined,
createNewBot: undefined,
})
.then(utils.handleExitCode)
// cleanup deployed integration
const { integration: deployedIntegration } = await client.getIntegrationByName({
name: integrationName,
version: '0.1.0',
})
if (!deployedIntegration.secrets.includes('REQUIRED_SECRET')) {
throw new Error(
`Integration ${integrationName} should have secret REQUIRED_SECRET, got: ${deployedIntegration.secrets.join(', ')}`
)
}
await client.deleteIntegration({ id: deployedIntegration.id })
if (await fetchIntegration(client, integrationName)) {
throw new Error(`Integration ${integrationName} should have been deleted`)
}
},
}
@@ -0,0 +1,228 @@
import { Client } from '@botpress/client'
import * as fs from 'fs'
import pathlib from 'path'
import impl from '../../src'
import { ApiIntegration, fetchAllIntegrations, ApiPlugin, fetchAllPlugins } from '../api'
import defaults from '../defaults'
import * as retry from '../retry'
import { Test } from '../typings'
import * as utils from '../utils'
const fetchIntegration = async (client: Client, integrationName: string): Promise<ApiIntegration | undefined> => {
const integrations = await fetchAllIntegrations(client)
return integrations.find(({ name }) => name === integrationName)
}
const fetchPlugin = async (client: Client, pluginName: string): Promise<ApiPlugin | undefined> => {
const plugins = await fetchAllPlugins(client)
return plugins.find(({ name }) => name === pluginName)
}
export const prependWorkspaceHandleIntegration: Test = {
name: 'cli should automatically preprend the workspace handle to the integration name when deploying',
handler: async ({ tmpDir, dependencies, workspaceHandle, logger, ...creds }) => {
const botpressHomeDir = pathlib.join(tmpDir, '.botpresshome')
const baseDir = pathlib.join(tmpDir, 'integrations')
const integrationSuffix = utils.getUUID()
const integrationName = `myintegration${integrationSuffix}`
const integrationNameWithHandle = `${workspaceHandle}/${integrationName}`
const integrationDirName = integrationName
const integrationDir = pathlib.join(baseDir, integrationDirName)
const argv = {
...defaults,
botpressHome: botpressHomeDir,
confirm: true,
...creds,
}
const client = new Client({
apiUrl: creds.apiUrl,
token: creds.token,
workspaceId: creds.workspaceId,
retry: retry.config,
})
await impl
.init({ ...argv, workDir: baseDir, name: integrationNameWithHandle, type: 'integration', template: 'empty' })
.then(utils.handleExitCode)
// Remove handle from package.json:
const pkgJsonPath = pathlib.join(integrationDir, 'package.json')
const pkgJson = await fs.promises.readFile(pkgJsonPath, 'utf-8').then(JSON.parse)
pkgJson.name = pkgJson.name.slice(workspaceHandle.length + 1)
await fs.promises.writeFile(pkgJsonPath, JSON.stringify(pkgJson, null, 2))
await utils.fixBotpressDependencies({ workDir: integrationDir, target: dependencies })
await utils.npmInstall({ workDir: integrationDir }).then(utils.handleExitCode)
await impl.build({ ...argv, workDir: integrationDir }).then(utils.handleExitCode)
await impl.login({ ...argv }).then(utils.handleExitCode)
await impl
.deploy({ ...argv, createNewBot: undefined, botId: undefined, workDir: integrationDir })
.then(utils.handleExitCode)
logger.debug(`Fetching integration "${integrationName}"`)
let integration = await fetchIntegration(client, integrationName)
if (integration) {
throw new Error(`Integration ${integrationName} should not have been created`)
}
const expectedIntegrationName = `${workspaceHandle}/${integrationName}`
logger.debug(`Fetching integration "${expectedIntegrationName}"`)
integration = await fetchIntegration(client, expectedIntegrationName)
if (!integration) {
throw new Error(`Integration ${expectedIntegrationName} should have been created`)
}
logger.debug(`Deleting integration "${integrationName}"`)
await impl.integrations.delete({ ...argv, integrationRef: integration.id }).then(({ exitCode }) => {
exitCode !== 0 && logger.warn(`Failed to delete integration "${integrationName}"`) // not enough to fail the test
})
},
}
export const prependWorkspaceHandlePlugin: Test = {
name: 'cli should automatically prepend the workspace handle to the plugin name when deploying',
handler: async ({ tmpDir, dependencies, workspaceHandle, logger, ...creds }) => {
const botpressHomeDir = pathlib.join(tmpDir, '.botpresshome')
const baseDir = pathlib.join(tmpDir, 'plugins')
const pluginSuffix = utils.getUUID()
const pluginName = `myplugin${pluginSuffix}`
const pluginNameWithHandle = `${workspaceHandle}/${pluginName}`
const pluginDir = pathlib.join(baseDir, pluginName)
const argv = {
...defaults,
botpressHome: botpressHomeDir,
confirm: true,
...creds,
}
const client = new Client({
apiUrl: creds.apiUrl,
token: creds.token,
workspaceId: creds.workspaceId,
retry: retry.config,
})
await impl
.init({ ...argv, workDir: baseDir, name: pluginNameWithHandle, type: 'plugin', template: 'empty' })
.then(utils.handleExitCode)
// Remove handle from package.json pluginName field:
const pkgJsonPath = pathlib.join(pluginDir, 'package.json')
const pkgJson = await fs.promises.readFile(pkgJsonPath, 'utf-8').then(JSON.parse)
pkgJson.pluginName = pluginName
await fs.promises.writeFile(pkgJsonPath, JSON.stringify(pkgJson, null, 2))
await utils.fixBotpressDependencies({ workDir: pluginDir, target: dependencies })
await utils.npmInstall({ workDir: pluginDir }).then(utils.handleExitCode)
await impl.build({ ...argv, workDir: pluginDir }).then(utils.handleExitCode)
await impl.login({ ...argv }).then(utils.handleExitCode)
await impl
.deploy({ ...argv, createNewBot: undefined, botId: undefined, workDir: pluginDir })
.then(utils.handleExitCode)
logger.debug(`Fetching plugin "${pluginName}"`)
let plugin = await fetchPlugin(client, pluginName)
if (plugin) {
throw new Error(`Plugin ${pluginName} should not have been created without handle prefix`)
}
const expectedPluginName = `${workspaceHandle}/${pluginName}`
logger.debug(`Fetching plugin "${expectedPluginName}"`)
plugin = await fetchPlugin(client, expectedPluginName)
if (!plugin) {
throw new Error(`Plugin ${expectedPluginName} should have been created`)
}
logger.debug(`Deleting plugin "${expectedPluginName}"`)
await impl.plugins.delete({ ...argv, pluginRef: plugin.id }).then(({ exitCode }) => {
exitCode !== 0 && logger.warn(`Failed to delete plugin "${expectedPluginName}"`)
})
},
}
export const enforceWorkspaceHandleIntegration: Test = {
name: 'cli should fail when attempting to deploy an integration with incorrect workspace handle',
handler: async ({ tmpDir, dependencies, ...creds }) => {
const botpressHomeDir = pathlib.join(tmpDir, '.botpresshome')
const baseDir = pathlib.join(tmpDir, 'integrations')
const randomSuffix = utils.getUUID().slice(0, 8)
const name = 'myintegration'
const handle = `myhandle${randomSuffix}`
const integrationName = `${handle}/${name}`
const integrationDir = pathlib.join(baseDir, name)
const argv = {
...defaults,
botpressHome: botpressHomeDir,
confirm: true,
...creds,
}
await impl
.init({ ...argv, workDir: baseDir, name: integrationName, type: 'integration', template: 'empty' })
.then(utils.handleExitCode)
await utils.fixBotpressDependencies({ workDir: integrationDir, target: dependencies })
await utils.npmInstall({ workDir: integrationDir }).then(utils.handleExitCode)
await impl.login({ ...argv }).then(utils.handleExitCode)
const { exitCode } = await impl.deploy({
...argv,
createNewBot: undefined,
botId: undefined,
workDir: integrationDir,
})
if (exitCode === 0) {
throw new Error(`Integration ${integrationName} should not have been deployed`)
}
},
}
export const enforceWorkspaceHandlePlugin: Test = {
name: 'cli should fail when attempting to deploy a plugin with incorrect workspace handle',
handler: async ({ tmpDir, dependencies, ...creds }) => {
const botpressHomeDir = pathlib.join(tmpDir, '.botpresshome')
const baseDir = pathlib.join(tmpDir, 'plugins')
const randomSuffix = utils.getUUID().slice(0, 8)
const name = 'myplugin'
const handle = `myhandle${randomSuffix}`
const pluginName = `${handle}/${name}`
const pluginDir = pathlib.join(baseDir, name)
const argv = {
...defaults,
botpressHome: botpressHomeDir,
confirm: true,
...creds,
}
await impl
.init({ ...argv, workDir: baseDir, name: pluginName, type: 'plugin', template: 'empty' })
.then(utils.handleExitCode)
await utils.fixBotpressDependencies({ workDir: pluginDir, target: dependencies })
await utils.npmInstall({ workDir: pluginDir }).then(utils.handleExitCode)
await impl.login({ ...argv }).then(utils.handleExitCode)
const { exitCode } = await impl.deploy({
...argv,
createNewBot: undefined,
botId: undefined,
workDir: pluginDir,
})
if (exitCode === 0) {
throw new Error(`Plugin ${pluginName} should not have been deployed`)
}
},
}
+92
View File
@@ -0,0 +1,92 @@
import * as fslib from 'fs'
import * as pathlib from 'path'
import * as uuid from 'uuid'
import impl from '../../src'
import defaults from '../defaults'
import { Test, TestProps } from '../typings'
import * as utils from '../utils'
const getHomeDir = (props: { tmpDir: string }) => pathlib.join(props.tmpDir, '.botpresshome')
const initBot = async (props: TestProps, definitionFile: string) => {
const { tmpDir, dependencies, ...creds } = props
const argv = {
...defaults,
botpressHome: getHomeDir(props),
confirm: true,
...creds,
}
const botName = uuid.v4().replace(/-/g, '')
const botDir = pathlib.join(tmpDir, botName)
await impl
.init({ ...argv, workDir: tmpDir, name: botName, type: 'bot', template: 'empty' })
.then(utils.handleExitCode)
await utils.fixBotpressDependencies({ workDir: botDir, target: dependencies })
await utils.npmInstall({ workDir: botDir }).then(utils.handleExitCode)
fslib.writeFileSync(pathlib.join(botDir, 'bot.definition.ts'), definitionFile)
return { botDir }
}
let botDir: string | undefined = undefined
const ALIAS = 'alias'
export const removePackage: Test = {
name: 'cli should allow removing a plugin',
handler: async (props) => {
try {
const botpressHomeDir = pathlib.join(props.tmpDir, '.botpresshome')
const initializedBot = await initBot(
props,
['import * as sdk from "@botpress/sdk"', 'export default new sdk.BotDefinition({})'].join('\n')
)
botDir = initializedBot.botDir
const argv = {
...defaults,
botpressHome: botpressHomeDir,
confirm: true,
...props,
}
await impl
.login({
...argv,
})
.then(utils.handleExitCode)
const plugin: string = 'hitl'
props.logger.info(`Installing plugin: ${plugin}`)
await impl
.add({
...argv,
packageRef: plugin,
installPath: botDir,
useDev: false,
alias: ALIAS,
})
.then(utils.handleExitCode)
await impl.remove({
...argv,
alias: ALIAS,
workDir: botDir,
})
const aliasPath = pathlib.join(botDir, 'bp_modules', ALIAS)
const exists = await fslib.promises
.access(aliasPath)
.then(() => true)
.catch(() => false)
if (exists) {
throw new Error(`Expected ${aliasPath} to not exist`)
}
const pkgJsonPath = pathlib.join(botDir, 'package.json')
const pkgJson = JSON.parse(fslib.readFileSync(pkgJsonPath, 'utf-8'))
if (pkgJson.bpDependencies && Object.keys(pkgJson.bpDependencies).length !== 0) {
throw new Error('Expected bpDependencies to be empty')
}
} finally {
if (botDir) fslib.rmSync(botDir, { force: true, recursive: true })
}
},
}
+19
View File
@@ -0,0 +1,19 @@
import { Logger } from '@bpinternal/log4bot'
export type TestProps = {
logger: Logger
tmpDir: string
workspaceId: string
workspaceHandle: string
token: string
apiUrl: string
tunnelUrl: string
dependencies: Record<string, string | undefined>
sdkPath?: string
clientPath?: string
}
export type Test = {
name: string
handler: (props: TestProps) => Promise<void>
}
+101
View File
@@ -0,0 +1,101 @@
import childprocess from 'child_process'
import fs from 'fs'
import _ from 'lodash'
import pathlib from 'path'
import tmp from 'tmp'
import * as uuid from 'uuid'
type PackageJson = {
name: string
version?: string
description?: string
scripts?: Record<string, string>
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
peerDependencies?: Record<string, string>
}
export const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
export class TmpDirectory {
private _closed = false
public static create() {
return new TmpDirectory(tmp.dirSync({ unsafeCleanup: true }))
}
private constructor(private _res: tmp.DirResult) {}
public get path() {
if (this._closed) {
throw new Error('Cannot access tmp directory after cleanup')
}
return this._res.name
}
public cleanup() {
if (this._closed) {
return
}
this._res.removeCallback()
}
}
export type RunCommandOptions = {
workDir: string
}
export type RunCommandOutput = {
exitCode: number
}
export const runCommand = async (cmd: string, { workDir }: RunCommandOptions): Promise<RunCommandOutput> => {
const [program, ...args] = cmd.split(' ')
if (!program) {
throw new Error('Cannot run empty command')
}
const { error, status } = childprocess.spawnSync(program, args, {
cwd: workDir,
stdio: 'inherit',
shell: true,
env: process.env,
})
if (error) {
throw error
}
return { exitCode: status ?? 0 }
}
export const npmInstall = ({ workDir }: RunCommandOptions): Promise<RunCommandOutput> => {
return runCommand('pnpm install', { workDir })
}
export const tscCheck = ({ workDir }: RunCommandOptions): Promise<RunCommandOutput> => {
return runCommand('tsc --noEmit', { workDir })
}
export const fixBotpressDependencies = async ({
workDir,
target,
}: {
workDir: string
target: Record<string, string | undefined>
}) => {
const packageJsonPath = pathlib.join(workDir, 'package.json')
const originalPackageJson: PackageJson = require(packageJsonPath)
const newPackageJson = {
...originalPackageJson,
dependencies: _.mapValues(originalPackageJson.dependencies ?? {}, (version, name) => target[name] ?? version),
}
fs.writeFileSync(packageJsonPath, JSON.stringify(newPackageJson, null, 2))
}
export const handleExitCode = ({ exitCode }: { exitCode: number }) => {
if (exitCode !== 0) {
throw new Error(`Command exited with code ${exitCode}`)
}
}
export const getUUID = () => uuid.v4().replace(/-/g, '')
+14
View File
@@ -0,0 +1,14 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
ignores: ['templates/**/*', 'e2e/fixtures/**/*'],
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+1
View File
@@ -0,0 +1 @@
require("./dist/init.js");
+77
View File
@@ -0,0 +1,77 @@
{
"name": "@botpress/cli",
"version": "6.8.8",
"description": "Botpress CLI",
"scripts": {
"build": "pnpm run build:types && pnpm run bundle && pnpm run template:gen",
"dev": "ts-node -T src/cli.ts",
"start": "node dist/cli.js",
"check:type": "tsc --noEmit",
"bundle": "ts-node -T build.ts",
"build:types": "tsc -p ./tsconfig.build.json",
"template:gen": "pnpm -r --stream -F @bp-templates/* exec bp gen",
"test:e2e": "ts-node -T ./e2e",
"test": "vitest --run"
},
"repository": {
"url": "https://github.com/botpress/botpress"
},
"keywords": [],
"author": "",
"license": "MIT",
"bin": {
"bp": "./bin.js"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"dependencies": {
"@apidevtools/json-schema-ref-parser": "^11.7.0",
"@botpress/chat": "1.0.0",
"@botpress/client": "1.47.0",
"@botpress/sdk": "6.13.0",
"@bpinternal/const": "^0.1.0",
"@bpinternal/tunnel": "^0.1.1",
"@bpinternal/verel": "^0.2.0",
"@bpinternal/yargs-extra": "^0.0.3",
"@parcel/watcher": "^2.1.0",
"@stoplight/spectral-core": "^1.19.1",
"@stoplight/spectral-functions": "^1.9.0",
"@stoplight/spectral-parsers": "^1.0.4",
"@stoplight/types": "^14.1.1",
"@types/lodash": "^4.14.191",
"@types/verror": "^1.10.6",
"axios": "^1.4.0",
"bluebird": "^3.7.2",
"boxen": "5.1.2",
"chalk": "^4.1.2",
"dotenv": "^16.4.4",
"esbuild": "^0.25.10",
"handlebars": "^4.7.8",
"jsonpath-plus": "^10.3.0",
"latest-version": "5.1.0",
"lodash": "^4.17.21",
"prettier": "^3.4.2",
"prompts": "^2.4.2",
"semver": "^7.3.8",
"uuid": "^9.0.0",
"verror": "^1.10.1",
"yn": "^4.0.0"
},
"devDependencies": {
"@bpinternal/log4bot": "^0.0.4",
"@types/bluebird": "^3.5.38",
"@types/ini": "^4.1.1",
"@types/json-schema": "^7.0.12",
"@types/prompts": "^2.0.14",
"@types/semver": "^7.3.11",
"@types/tmp": "^0.2.3",
"@types/uuid": "^9.0.1",
"find-process": "^2.0.0",
"glob": "^9.3.4",
"tmp": "^0.2.1"
},
"engines": {
"node": ">=18.0.0"
},
"packageManager": "pnpm@10.29.3"
}
+19
View File
@@ -0,0 +1,19 @@
# Botpress CLI
Official Botpress CLI. Made to query to BotpressAPI and simplify the development of bots and integrations as code.
## Installation
```bash
npm install --save @botpress/cli # for npm
yarn add @botpress/cli # for yarn
pnpm add @botpress/cli # for pnpm
```
## Usage
```bash
bp login # login to Botpress Cloud
bp bots ls # list all bots
bp --help # list all commands
```
+129
View File
@@ -0,0 +1,129 @@
import * as client from '@botpress/client'
import * as sdk from '@botpress/sdk'
import * as errors from '../errors'
import * as utils from '../utils'
import * as types from './types'
export const prepareCreateBotBody = async (bot: sdk.BotDefinition): Promise<types.CreateBotRequestBody> => ({
user: bot.user,
conversation: bot.conversation,
message: bot.message,
recurringEvents: bot.recurringEvents,
actions: bot.actions
? await utils.records.mapValuesAsync(bot.actions, async (action, actionName) => ({
...action,
input: {
...action.input,
schema: await utils.schema
.mapZodToJsonSchema(action.input, {
useLegacyZuiTransformer: bot.__advanced?.useLegacyZuiTransformer,
toJSONSchemaOptions: bot.__advanced?.toJSONSchemaOptions,
})
.catch((thrown) => {
throw errors.BotpressCLIError.wrap(
thrown,
`Failed to convert ZUI to JSON schema for bot action ${actionName} input`
)
}),
},
output: {
...action.output,
schema: await utils.schema
.mapZodToJsonSchema(action.output, {
useLegacyZuiTransformer: bot.__advanced?.useLegacyZuiTransformer,
toJSONSchemaOptions: bot.__advanced?.toJSONSchemaOptions,
})
.catch((thrown) => {
throw errors.BotpressCLIError.wrap(
thrown,
`Failed to convert ZUI to JSON schema for bot action ${actionName} output`
)
}),
},
}))
: undefined,
configuration: bot.configuration
? {
...bot.configuration,
schema: await utils.schema
.mapZodToJsonSchema(bot.configuration, {
useLegacyZuiTransformer: bot.__advanced?.useLegacyZuiTransformer,
toJSONSchemaOptions: bot.__advanced?.toJSONSchemaOptions,
})
.catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, 'Failed to convert ZUI to JSON schema for bot configuration')
}),
}
: undefined,
events: bot.events
? await utils.records.mapValuesAsync(bot.events, async (event, eventName) => ({
...event,
schema: await utils.schema
.mapZodToJsonSchema(event, {
useLegacyZuiTransformer: bot.__advanced?.useLegacyZuiTransformer,
toJSONSchemaOptions: bot.__advanced?.toJSONSchemaOptions,
})
.catch((thrown) => {
throw errors.BotpressCLIError.wrap(
thrown,
`Failed to convert ZUI to JSON schema for bot event ${eventName}`
)
}),
}))
: undefined,
states: bot.states
? (utils.records.filterValues(
await utils.records.mapValuesAsync(bot.states, async (state, stateName) => ({
...state,
schema: await utils.schema
.mapZodToJsonSchema(state, {
useLegacyZuiTransformer: bot.__advanced?.useLegacyZuiTransformer,
toJSONSchemaOptions: bot.__advanced?.toJSONSchemaOptions,
})
.catch((thrown) => {
throw errors.BotpressCLIError.wrap(
thrown,
`Failed to convert ZUI to JSON schema for bot state ${stateName}`
)
}),
})),
({ type }) => type !== 'workflow'
) as types.CreateBotRequestBody['states'])
: undefined,
tags: bot.attributes,
})
export const prepareUpdateBotBody = (
localBot: types.UpdateBotRequestBody,
remoteBot: client.Bot
): types.UpdateBotRequestBody => ({
...localBot,
states: utils.records.setNullOnMissingValues(localBot.states, remoteBot.states),
recurringEvents: utils.records.setNullOnMissingValues(localBot.recurringEvents, remoteBot.recurringEvents),
events: utils.attributes.prepareAttributeUpdateBody({
localItems: utils.records.setNullOnMissingValues(localBot.events, remoteBot.events),
remoteItems: remoteBot.events,
}),
actions: utils.attributes.prepareAttributeUpdateBody({
localItems: utils.records.setNullOnMissingValues(localBot.actions, remoteBot.actions),
remoteItems: remoteBot.actions,
}),
user: {
...localBot.user,
tags: utils.records.setNullOnMissingValues(localBot.user?.tags, remoteBot.user?.tags),
},
conversation: {
...localBot.conversation,
tags: utils.records.setNullOnMissingValues(localBot.conversation?.tags, remoteBot.conversation?.tags),
},
message: {
...localBot.message,
tags: utils.records.setNullOnMissingValues(localBot.message?.tags, remoteBot.message?.tags),
},
integrations: utils.integrations.prepareIntegrationsUpdate(
utils.records.setNullOnMissingValues(localBot.integrations, remoteBot.integrations),
remoteBot.integrations
),
plugins: utils.records.setNullOnMissingValues(localBot.plugins, remoteBot.plugins),
tags: localBot.tags, // TODO: allow removing bot tags (aka attributes) by setting to null
})
+348
View File
@@ -0,0 +1,348 @@
import * as client from '@botpress/client'
import semver from 'semver'
import yn from 'yn'
import * as errors from '../errors'
import type { Logger } from '../logger'
import { formatPackageRef, ApiPackageRef, NamePackageRef } from '../package-ref'
import * as utils from '../utils'
import * as paging from './paging'
import * as retry from './retry'
import {
ApiClientProps,
PublicOrUnlistedIntegration,
PrivateIntegration,
PublicOrPrivateIntegration,
PublicInterface,
PrivateInterface,
PublicOrPrivateInterface,
PrivatePlugin,
PublicPlugin,
PublicOrPrivatePlugin,
BotSummary,
} from './types'
export * from './types'
/**
* This class is used to wrap the Botpress API and provide a more convenient way to interact with it.
*/
export class ApiClient {
public readonly client: client.Client
public readonly url: string
public readonly token: string
public readonly workspaceId: string
public readonly botId?: string
public readonly extraHeaders: Record<string, string>
public static newClient = (props: ApiClientProps, logger: Logger) => new ApiClient(props, logger)
public constructor(
props: ApiClientProps,
private _logger: Logger
) {
const { apiUrl, token, workspaceId, botId, extraHeaders = {} } = props
this.client = new client.Client({
apiUrl,
token,
workspaceId,
botId,
retry: retry.config,
headers: { 'x-multiple-integrations': 'true', ...extraHeaders },
})
this.url = apiUrl
this.token = token
this.workspaceId = workspaceId
this.botId = botId
this.extraHeaders = extraHeaders
}
public get isBotpressWorkspace(): boolean {
// this environment variable is undocumented and only used internally for dev purposes
const isBotpressWorkspace = yn(process.env.BP_IS_BOTPRESS_WORKSPACE)
if (isBotpressWorkspace !== undefined) {
return isBotpressWorkspace
}
return [
'6a76fa10-e150-4ff6-8f59-a300feec06c1',
'95de33eb-1551-4af9-9088-e5dcb02efd09',
'11111111-1111-1111-aaaa-111111111111',
].includes(this.workspaceId)
}
public async safeListTables(req: client.ClientInputs['listTables']): Promise<
| {
success: true
tables: client.ClientOutputs['listTables']['tables']
}
| {
success: false
error: Error
}
> {
try {
const result = await this.client.listTables(req)
return { success: true, tables: result.tables }
} catch (thrown) {
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
return { success: false, error }
}
}
public async getWorkspace(): Promise<client.ClientOutputs['getWorkspace']> {
return this.client.getWorkspace({ id: this.workspaceId })
}
public async findWorkspaceByHandle(handle: string): Promise<client.ClientOutputs['getWorkspace'] | undefined> {
const { workspaces } = await this.client.listWorkspaces({ handle })
return workspaces[0] // There should be only one workspace with a given handle
}
public withExtraHeaders(headers: Record<string, string>): ApiClient {
return ApiClient.newClient(
{
apiUrl: this.url,
token: this.token,
workspaceId: this.workspaceId,
botId: this.botId,
extraHeaders: { ...this.extraHeaders, ...headers },
},
this._logger
)
}
public switchWorkspace(workspaceId: string): ApiClient {
return ApiClient.newClient(
{ apiUrl: this.url, token: this.token, workspaceId, extraHeaders: this.extraHeaders },
this._logger
)
}
public switchBot(botId: string): ApiClient {
return ApiClient.newClient(
{ apiUrl: this.url, token: this.token, botId, workspaceId: this.workspaceId, extraHeaders: this.extraHeaders },
this._logger
)
}
public async updateWorkspace(
props: utils.types.SafeOmit<client.ClientInputs['updateWorkspace'], 'id'>
): Promise<client.ClientOutputs['updateWorkspace']> {
return this.client.updateWorkspace({ id: this.workspaceId, ...props })
}
public async getPublicOrPrivateIntegration(ref: ApiPackageRef): Promise<PublicOrPrivateIntegration> {
const integration = await this.findPublicOrPrivateIntegration(ref)
if (!integration) {
throw new Error(`Integration "${formatPackageRef(ref)}" not found`)
}
return integration
}
public async findPublicOrPrivateIntegration(ref: ApiPackageRef): Promise<PublicOrPrivateIntegration | undefined> {
const formatted = formatPackageRef(ref)
const privateIntegration = await this.findPrivateIntegration(ref)
if (privateIntegration) {
this._logger.debug(`Found integration "${formatted}" in workspace`)
return privateIntegration
}
const publicIntegration = await this.findPublicIntegration(ref)
if (publicIntegration) {
this._logger.debug(`Found integration "${formatted}" in hub`)
return publicIntegration
}
return
}
public async findPrivateIntegration(ref: ApiPackageRef): Promise<PrivateIntegration | undefined> {
const { workspaceId } = this
if (ref.type === 'id') {
return this.client
.getIntegration(ref)
.then((r) => ({ ...r.integration, workspaceId }))
.catch(this._returnUndefinedOnError('ResourceNotFound'))
}
return this.client
.getIntegrationByName(ref)
.then((r) => ({ ...r.integration, workspaceId }))
.catch(this._returnUndefinedOnError('ResourceNotFound'))
}
public async findPublicIntegration(ref: ApiPackageRef): Promise<PublicOrUnlistedIntegration | undefined> {
if (ref.type === 'id') {
return this.client
.getPublicIntegrationById(ref)
.then((r) => ({ ...r.integration, visibility: r.integration.visibility as 'public' | 'unlisted' }) as const)
.catch(this._returnUndefinedOnError('ResourceNotFound'))
}
return this.client
.getPublicIntegration(ref)
.then((r) => ({ ...r.integration, visibility: r.integration.visibility as 'public' | 'unlisted' }) as const)
.catch(this._returnUndefinedOnError('ResourceNotFound'))
}
public async findPublicOrPrivateInterface(ref: ApiPackageRef): Promise<PublicOrPrivateInterface | undefined> {
const formatted = formatPackageRef(ref)
const privateInterface = await this.findPrivateInterface(ref)
if (privateInterface) {
this._logger.debug(`Found interface "${formatted}" in workspace`)
return privateInterface
}
const publicInterface = await this.findPublicInterface(ref)
if (publicInterface) {
this._logger.debug(`Found interface "${formatted}" in hub`)
return publicInterface
}
return
}
public async findPrivateInterface(ref: ApiPackageRef): Promise<PrivateInterface | undefined> {
const { workspaceId } = this
if (ref.type === 'id') {
return this.client
.getInterface(ref)
.then((r) => ({ ...r.interface, workspaceId }))
.catch(this._returnUndefinedOnError('ResourceNotFound'))
}
return this.client
.getInterfaceByName(ref)
.then((r) => ({ ...r.interface, workspaceId }))
.catch(this._returnUndefinedOnError('ResourceNotFound'))
}
public async getPublicInterface(ref: ApiPackageRef): Promise<PublicInterface> {
const intrface = await this.findPublicInterface(ref)
if (!intrface) {
throw new Error(`Interface "${formatPackageRef(ref)}" not found`)
}
return intrface
}
public async findPublicInterface(ref: ApiPackageRef): Promise<PublicInterface | undefined> {
if (ref.type === 'id') {
return this.client
.getPublicInterfaceById(ref)
.then((r) => ({ ...r.interface, public: true }) as const)
.catch(this._returnUndefinedOnError('ResourceNotFound'))
}
return this.client
.getPublicInterface(ref)
.then((r) => ({ ...r.interface, public: true }) as const)
.catch(this._returnUndefinedOnError('ResourceNotFound'))
}
public async findPublicPlugin(ref: ApiPackageRef): Promise<PublicPlugin | undefined> {
if (ref.type === 'id') {
return this.client
.getPublicPluginById(ref)
.then((r) => ({ ...r.plugin, public: true }) as const)
.catch(this._returnUndefinedOnError('ResourceNotFound'))
}
return this.client
.getPublicPlugin(ref)
.then((r) => ({ ...r.plugin, public: true }) as const)
.catch(this._returnUndefinedOnError('ResourceNotFound'))
}
public async getPublicOrPrivatePlugin(ref: ApiPackageRef): Promise<PublicOrPrivatePlugin> {
const plugin = await this.findPublicOrPrivatePlugin(ref)
if (!plugin) {
throw new Error(`Plugin "${formatPackageRef(ref)}" not found`)
}
return plugin
}
public async findPublicOrPrivatePlugin(ref: ApiPackageRef): Promise<PublicOrPrivatePlugin | undefined> {
const formatted = formatPackageRef(ref)
const privatePlugin = await this.findPrivatePlugin(ref)
if (privatePlugin) {
this._logger.debug(`Found plugin "${formatted}" in workspace`)
return privatePlugin
}
const publicPlugin = await this.findPublicPlugin(ref)
if (publicPlugin) {
this._logger.debug(`Found plugin "${formatted}" in hub`)
return publicPlugin
}
return
}
public async findPrivatePlugin(ref: ApiPackageRef): Promise<PrivatePlugin | undefined> {
const { workspaceId } = this
if (ref.type === 'id') {
return this.client
.getPlugin(ref)
.then((r) => ({ ...r.plugin, workspaceId }))
.catch(this._returnUndefinedOnError('ResourceNotFound'))
}
return this.client
.getPluginByName(ref)
.then((r) => ({ ...r.plugin, workspaceId }))
.catch(this._returnUndefinedOnError('ResourceNotFound'))
}
public async testLogin(): Promise<void> {
await this.client.listBots({})
}
public listAllPages = paging.listAllPages
public async findPreviousIntegrationVersion(ref: NamePackageRef): Promise<PublicOrPrivateIntegration | undefined> {
const isValidSemverVersion = semver.valid(ref.version)
// Sanity check (this should never happen):
if (!isValidSemverVersion) {
throw new errors.BotpressCLIError(`Invalid version "${ref.version}" for integration "${ref.name}"`)
}
return this.findPublicOrPrivateIntegration({ ...ref, version: `<${ref.version}` })
}
public async findBotByName(name: string): Promise<BotSummary | undefined> {
// api does not allow filtering bots by name
const allBots = await this.listAllPages(this.client.listBots, (r) => r.bots)
return allBots.find((b) => b.name === name)
}
private _returnUndefinedOnError =
(type: client.ApiError['type']) =>
(thrown: any): undefined => {
if (client.isApiError(thrown) && thrown.type === type) {
return
}
throw thrown
}
public async getOrGenerateShareableId(
botId: string,
integrationId: string,
integrationAlias: string
): Promise<string> {
const { shareableId, isExpired } = await this.client
.getIntegrationShareableId({
botId,
integrationId,
integrationInstanceAlias: integrationAlias,
})
.catch(() => ({ shareableId: undefined, isExpired: true }))
if (shareableId && !isExpired) {
return shareableId
}
const { shareableId: newShareableId } = await this.client.createIntegrationShareableId({
botId,
integrationId,
integrationInstanceAlias: integrationAlias,
})
return newShareableId
}
}
+6
View File
@@ -0,0 +1,6 @@
export * from './client'
export * from './types'
export * from './integration-body'
export * from './interface-body'
export * from './bot-body'
export * from './plugin-body'
+243
View File
@@ -0,0 +1,243 @@
import * as client from '@botpress/client'
import * as sdk from '@botpress/sdk'
import * as errors from '../errors'
import * as utils from '../utils'
import * as types from './types'
export const prepareCreateIntegrationBody = async (
integration: sdk.IntegrationDefinition
): Promise<types.CreateIntegrationRequestBody> => {
const base = `Failed to convert ZUI to JSON schema for integration ${integration.name}`
return {
name: integration.name,
version: integration.version,
title: 'title' in integration ? integration.title : undefined,
description: 'description' in integration ? integration.description : undefined,
user: integration.user,
events: integration.events
? await utils.records.mapValuesAsync(integration.events, async (event, eventName) => ({
...event,
schema: await utils.schema
.mapZodToJsonSchema(event, {
useLegacyZuiTransformer: integration.__advanced?.useLegacyZuiTransformer,
toJSONSchemaOptions: integration.__advanced?.toJSONSchemaOptions,
})
.catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, `${base} for event ${eventName}`)
}),
}))
: undefined,
actions: integration.actions
? await utils.records.mapValuesAsync(integration.actions, async (action, actionName) => ({
...action,
input: {
...action.input,
schema: await utils.schema
.mapZodToJsonSchema(action.input, {
useLegacyZuiTransformer: integration.__advanced?.useLegacyZuiTransformer,
toJSONSchemaOptions: integration.__advanced?.toJSONSchemaOptions,
})
.catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, `${base} for action ${actionName} input`)
}),
},
output: {
...action.output,
schema: await utils.schema
.mapZodToJsonSchema(action.output, {
useLegacyZuiTransformer: integration.__advanced?.useLegacyZuiTransformer,
toJSONSchemaOptions: integration.__advanced?.toJSONSchemaOptions,
})
.catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, `${base} for action ${actionName} output`)
}),
},
}))
: undefined,
channels: integration.channels
? await utils.records.mapValuesAsync(integration.channels, async (channel, channelName) => ({
...channel,
messages: await utils.records.mapValuesAsync(channel.messages, async (message, messageName) => ({
...message,
schema: await utils.schema
.mapZodToJsonSchema(message, {
useLegacyZuiTransformer: integration.__advanced?.useLegacyZuiTransformer,
toJSONSchemaOptions: integration.__advanced?.toJSONSchemaOptions,
})
.catch((thrown) => {
throw errors.BotpressCLIError.wrap(
thrown,
`${base} for channel ${channelName} for message ${messageName}`
)
}),
})),
}))
: undefined,
states: integration.states
? await utils.records.mapValuesAsync(integration.states, async (state, stateName) => ({
...state,
schema: await utils.schema
.mapZodToJsonSchema(state, {
useLegacyZuiTransformer: integration.__advanced?.useLegacyZuiTransformer,
toJSONSchemaOptions: integration.__advanced?.toJSONSchemaOptions,
})
.catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, `${base} for state ${stateName}`)
}),
}))
: undefined,
entities: integration.entities
? await utils.records.mapValuesAsync(integration.entities, async (entity, entityName) => ({
...entity,
schema: await utils.schema
.mapZodToJsonSchema(entity, {
useLegacyZuiTransformer: integration.__advanced?.useLegacyZuiTransformer,
toJSONSchemaOptions: integration.__advanced?.toJSONSchemaOptions,
})
.catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, `${base} for entity ${entityName}`)
}),
}))
: undefined,
attributes: integration.attributes,
extraOperations: '__advanced' in integration ? integration.__advanced?.extraOperations : undefined,
}
}
type UpdateIntegrationChannelsBody = NonNullable<types.UpdateIntegrationRequestBody['channels']>
type UpdateIntegrationChannelBody = UpdateIntegrationChannelsBody[string]
type Channels = client.Integration['channels']
type Channel = client.Integration['channels'][string]
export const prepareUpdateIntegrationBody = (
localIntegration: types.UpdateIntegrationRequestBody,
remoteIntegration: client.Integration
): types.UpdateIntegrationRequestBody => {
const actions = utils.attributes.prepareAttributeUpdateBody({
localItems: utils.records.setNullOnMissingValues(localIntegration.actions, remoteIntegration.actions),
remoteItems: remoteIntegration.actions,
})
const events = utils.attributes.prepareAttributeUpdateBody({
localItems: utils.records.setNullOnMissingValues(localIntegration.events, remoteIntegration.events),
remoteItems: remoteIntegration.events,
})
const states = utils.records.setNullOnMissingValues(localIntegration.states, remoteIntegration.states)
const entities = utils.records.setNullOnMissingValues(localIntegration.entities, remoteIntegration.entities)
const user = {
...localIntegration.user,
tags: utils.records.setNullOnMissingValues(localIntegration.user?.tags, remoteIntegration.user?.tags),
}
const channels = _prepareUpdateIntegrationChannelsBody(localIntegration.channels ?? {}, remoteIntegration.channels)
const interfaces = utils.records.setNullOnMissingValues(localIntegration.interfaces, remoteIntegration.interfaces)
const configurations = utils.records.setNullOnMissingValues(
localIntegration.configurations,
remoteIntegration.configurations
)
const readme = localIntegration.readme
const icon = localIntegration.icon
const attributes = utils.records.setNullOnMissingValues(localIntegration.attributes, remoteIntegration.attributes)
const extraOperations = localIntegration.extraOperations
return {
..._maybeRemoveVrlScripts(localIntegration, remoteIntegration),
actions,
events,
states,
entities,
user,
channels,
interfaces,
configurations,
readme,
icon,
attributes,
extraOperations,
}
}
const _maybeRemoveVrlScripts = (
localIntegration: types.UpdateIntegrationRequestBody,
remoteIntegration: client.Integration
): types.UpdateIntegrationRequestBody => {
const newIntegration = structuredClone(localIntegration)
if (
remoteIntegration.configuration?.identifier?.linkTemplateScript &&
!localIntegration.configuration?.identifier?.linkTemplateScript
) {
newIntegration.configuration ??= remoteIntegration.configuration
newIntegration.configuration.identifier ??= remoteIntegration.configuration.identifier
newIntegration.configuration.identifier.linkTemplateScript = null
newIntegration.configuration.identifier.required = false
}
if (remoteIntegration.identifier.extractScript && !localIntegration.identifier?.extractScript) {
newIntegration.identifier ??= remoteIntegration.identifier
newIntegration.identifier.extractScript = null
}
if (remoteIntegration.identifier.fallbackHandlerScript && !localIntegration.identifier?.fallbackHandlerScript) {
newIntegration.identifier ??= remoteIntegration.identifier
newIntegration.identifier.fallbackHandlerScript = null
}
for (const configName of Object.keys(localIntegration.configurations ?? {})) {
if (
remoteIntegration.configurations[configName]?.identifier.linkTemplateScript &&
!localIntegration.configurations?.[configName]?.identifier?.linkTemplateScript
) {
newIntegration.configurations ??= remoteIntegration.configurations
newIntegration.configurations[configName] ??= remoteIntegration.configurations[configName]
newIntegration.configurations[configName].identifier ??= remoteIntegration.configurations[configName].identifier
newIntegration.configurations[configName].identifier.linkTemplateScript = null
newIntegration.configurations[configName].identifier.required = false
}
}
return newIntegration
}
const _prepareUpdateIntegrationChannelsBody = (
localChannels: UpdateIntegrationChannelsBody,
remoteChannels: Channels
): UpdateIntegrationChannelsBody => {
const channelBody: UpdateIntegrationChannelsBody = {}
const zipped = utils.records.zipObjects(localChannels, remoteChannels)
for (const [channelName, [localChannel, remoteChannel]] of Object.entries(zipped)) {
if (localChannel && remoteChannel) {
// channel has to be updated
channelBody[channelName] = _prepareUpdateIntegrationChannelBody(localChannel, remoteChannel)
} else if (localChannel) {
// channel has to be created
channelBody[channelName] = localChannel
continue
} else if (remoteChannel) {
// channel has to be deleted
channelBody[channelName] = null
continue
}
}
return channelBody
}
const _prepareUpdateIntegrationChannelBody = (
localChannel: UpdateIntegrationChannelBody,
remoteChannel: Channel
): UpdateIntegrationChannelBody => ({
...localChannel,
messages: utils.records.setNullOnMissingValues(localChannel?.messages, remoteChannel.messages),
message: {
...localChannel?.message,
tags: utils.records.setNullOnMissingValues(localChannel?.message?.tags, remoteChannel.message.tags),
},
conversation: {
...localChannel?.conversation,
tags: utils.records.setNullOnMissingValues(localChannel?.conversation?.tags, remoteChannel.conversation.tags),
},
})
+128
View File
@@ -0,0 +1,128 @@
import * as client from '@botpress/client'
import * as sdk from '@botpress/sdk'
import * as errors from '../errors'
import * as utils from '../utils'
import * as types from './types'
export const prepareCreateInterfaceBody = async (
intrface: sdk.InterfaceDefinition
): Promise<types.CreateInterfaceRequestBody> => {
const base = `Failed to convert ZUI to JSON schema for interface ${intrface.name}`
return {
name: intrface.name,
version: intrface.version,
title: 'title' in intrface ? intrface.title : undefined,
description: 'description' in intrface ? intrface.description : undefined,
entities: intrface.entities
? await utils.records.mapValuesAsync(intrface.entities, async (entity, entityName) => ({
...entity,
schema: await utils.schema
.mapZodToJsonSchema(entity, {
useLegacyZuiTransformer: intrface.__advanced?.useLegacyZuiTransformer,
toJSONSchemaOptions: intrface.__advanced?.toJSONSchemaOptions,
})
.catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, `${base} for entity ${entityName}`)
}),
}))
: {},
events: intrface.events
? await utils.records.mapValuesAsync(intrface.events, async (event, eventName) => ({
...event,
schema: await utils.schema
.mapZodToJsonSchema(event, {
useLegacyZuiTransformer: intrface.__advanced?.useLegacyZuiTransformer,
toJSONSchemaOptions: intrface.__advanced?.toJSONSchemaOptions,
})
.catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, `${base} for event ${eventName}`)
}),
}))
: {},
actions: intrface.actions
? await utils.records.mapValuesAsync(intrface.actions, async (action, actionName) => ({
...action,
input: {
...action.input,
schema: await utils.schema
.mapZodToJsonSchema(action.input, {
useLegacyZuiTransformer: intrface.__advanced?.useLegacyZuiTransformer,
toJSONSchemaOptions: intrface.__advanced?.toJSONSchemaOptions,
})
.catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, `${base} for action ${actionName} input`)
}),
},
output: {
...action.output,
schema: await utils.schema
.mapZodToJsonSchema(action.output, {
useLegacyZuiTransformer: intrface.__advanced?.useLegacyZuiTransformer,
toJSONSchemaOptions: intrface.__advanced?.toJSONSchemaOptions,
})
.catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, `${base} for action ${actionName} output`)
}),
},
}))
: {},
channels: intrface.channels
? await utils.records.mapValuesAsync(intrface.channels, async (channel, channelName) => ({
...channel,
messages: await utils.records.mapValuesAsync(channel.messages, async (message, messageName) => ({
...message,
schema: await utils.schema
.mapZodToJsonSchema(message, {
useLegacyZuiTransformer: intrface.__advanced?.useLegacyZuiTransformer,
toJSONSchemaOptions: intrface.__advanced?.toJSONSchemaOptions,
})
.catch((thrown) => {
throw errors.BotpressCLIError.wrap(
thrown,
`${base} for channel ${channelName} for message ${messageName}`
)
}),
})),
}))
: {},
attributes: intrface.attributes,
}
}
export const prepareUpdateInterfaceBody = (
localInterface: types.CreateInterfaceRequestBody & { id: string },
remoteInterface: client.Interface
): types.UpdateInterfaceRequestBody => {
const actions = utils.attributes.prepareAttributeUpdateBody({
localItems: utils.records.setNullOnMissingValues(localInterface.actions, remoteInterface.actions),
remoteItems: remoteInterface.actions,
})
const events = utils.attributes.prepareAttributeUpdateBody({
localItems: utils.records.setNullOnMissingValues(localInterface.events, remoteInterface.events),
remoteItems: remoteInterface.events,
})
const entities = utils.records.setNullOnMissingValues(localInterface.entities, remoteInterface.entities)
const currentChannels: types.UpdateInterfaceRequestBody['channels'] = localInterface.channels
? utils.records.mapValues(localInterface.channels, (channel, channelName) => ({
...channel,
messages: utils.records.setNullOnMissingValues(
channel?.messages,
remoteInterface.channels[channelName]?.messages
),
}))
: undefined
const channels = utils.records.setNullOnMissingValues(currentChannels, remoteInterface.channels)
const attributes = utils.records.setNullOnMissingValues(localInterface.attributes, remoteInterface.attributes)
return {
...localInterface,
entities,
actions,
events,
channels,
attributes,
}
}
+21
View File
@@ -0,0 +1,21 @@
export type PageLister<R extends object> = (t: { nextToken?: string }) => Promise<R & { meta: { nextToken?: string } }>
export async function listAllPages<R extends object>(lister: PageLister<R>): Promise<R[]>
export async function listAllPages<R extends object, M>(lister: PageLister<R>, mapper?: (r: R) => M[]): Promise<M[]>
export async function listAllPages<R extends object, M>(lister: PageLister<R>, mapper?: (r: R) => M[]) {
let nextToken: string | undefined
const all: R[] = []
do {
const { meta, ...r } = await lister({ nextToken })
all.push(r as R)
nextToken = meta.nextToken
} while (nextToken)
if (!mapper) {
return all
}
const mapped: M[] = all.flatMap((r) => mapper(r))
return mapped
}
+133
View File
@@ -0,0 +1,133 @@
import * as client from '@botpress/client'
import * as sdk from '@botpress/sdk'
import * as errors from '../errors'
import * as utils from '../utils'
import * as types from './types'
export const prepareCreatePluginBody = async (plugin: sdk.PluginDefinition): Promise<types.CreatePluginRequestBody> => {
const base = `Failed to convert ZUI to JSON schema for plugin ${plugin.name}`
return {
name: plugin.name,
version: plugin.version,
title: 'title' in plugin ? plugin.title : undefined,
description: 'description' in plugin ? plugin.description : undefined,
user: {
tags: plugin.user?.tags ?? {},
},
conversation: {
tags: plugin.conversation?.tags ?? {},
},
message: {
tags: plugin.message?.tags ?? {},
},
configuration: plugin.configuration
? {
...plugin.configuration,
schema: await utils.schema
.mapZodToJsonSchema(plugin.configuration, {
useLegacyZuiTransformer: plugin.__advanced?.useLegacyZuiTransformer,
toJSONSchemaOptions: plugin.__advanced?.toJSONSchemaOptions,
})
.catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, `${base} for configuration`)
}),
}
: undefined,
events: plugin.events
? await utils.records.mapValuesAsync(plugin.events, async (event, eventName) => ({
...event,
schema: await utils.schema
.mapZodToJsonSchema(event, {
useLegacyZuiTransformer: plugin.__advanced?.useLegacyZuiTransformer,
toJSONSchemaOptions: plugin.__advanced?.toJSONSchemaOptions,
})
.catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, `${base} for event ${eventName}`)
}),
}))
: undefined,
actions: plugin.actions
? await utils.records.mapValuesAsync(plugin.actions, async (action, actionName) => ({
...action,
input: {
...action.input,
schema: await utils.schema
.mapZodToJsonSchema(action.input, {
useLegacyZuiTransformer: plugin.__advanced?.useLegacyZuiTransformer,
toJSONSchemaOptions: plugin.__advanced?.toJSONSchemaOptions,
})
.catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, `${base} for action ${actionName} input`)
}),
},
output: {
...action.output,
schema: await utils.schema
.mapZodToJsonSchema(action.output, {
useLegacyZuiTransformer: plugin.__advanced?.useLegacyZuiTransformer,
toJSONSchemaOptions: plugin.__advanced?.toJSONSchemaOptions,
})
.catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, `${base} for action ${actionName} output`)
}),
},
}))
: undefined,
states: plugin.states
? (utils.records.filterValues(
await utils.records.mapValuesAsync(plugin.states, async (state, stateName) => ({
...state,
schema: await utils.schema
.mapZodToJsonSchema(state, {
useLegacyZuiTransformer: plugin.__advanced?.useLegacyZuiTransformer,
toJSONSchemaOptions: plugin.__advanced?.toJSONSchemaOptions,
})
.catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, `${base} for state ${stateName}`)
}),
})),
({ type }) => type !== 'workflow'
) as types.CreatePluginRequestBody['states'])
: undefined,
attributes: plugin.attributes,
}
}
export const prepareUpdatePluginBody = (
localPlugin: types.UpdatePluginRequestBody,
remotePlugin: client.Plugin
): types.UpdatePluginRequestBody => {
const actions = utils.attributes.prepareAttributeUpdateBody({
localItems: utils.records.setNullOnMissingValues(localPlugin.actions, remotePlugin.actions),
remoteItems: remotePlugin.actions,
})
const events = utils.attributes.prepareAttributeUpdateBody({
localItems: utils.records.setNullOnMissingValues(localPlugin.events, remotePlugin.events),
remoteItems: remotePlugin.events,
})
const states = utils.records.setNullOnMissingValues(localPlugin.states, remotePlugin.states)
const attributes = utils.records.setNullOnMissingValues(localPlugin.attributes, remotePlugin.attributes)
const dependencies: types.UpdatePluginRequestBody['dependencies'] = {
integrations: utils.records.setNullOnMissingValues(
localPlugin.dependencies?.integrations,
remotePlugin.dependencies?.integrations
),
interfaces: utils.records.setNullOnMissingValues(
localPlugin.dependencies?.interfaces,
remotePlugin.dependencies?.interfaces
),
}
// TODO: set null to conversation, user and message tags that are removed
return {
...localPlugin,
actions,
events,
states,
attributes,
dependencies,
}
}
+48
View File
@@ -0,0 +1,48 @@
import * as client from '@botpress/client'
// TODO: we probably shouldnt retry on 500 errors, but this is a temporary fix for the botpress repo CI
const HTTP_STATUS_TO_RETRY_ON = [429, 500, 502, 503, 504]
function getRetryAfterMs(error: Parameters<NonNullable<client.RetryConfig['retryDelay']>>[1]): number | undefined {
const headers = error?.response?.headers
if (!headers) return undefined
const headerNames = ['retry-after', 'ratelimit-reset', 'x-ratelimit-reset']
for (const name of headerNames) {
const raw = headers[name]
if (!raw) continue
const value = String(raw)
// HTTP-date format (e.g. "Mon, 28 Apr 2026 12:00:00 GMT")
if (value.includes(' ')) {
const futureDate = new Date(value)
if (!isNaN(futureDate.getTime())) {
return Math.max(0, futureDate.getTime() - Date.now())
}
continue
}
// Seconds format (e.g. "120")
const seconds = parseInt(value, 10)
if (!isNaN(seconds) && seconds >= 0) {
return seconds * 1000
}
}
return undefined
}
export const config: client.RetryConfig = {
retries: 3,
retryCondition: (err) =>
client.axiosRetry.isNetworkOrIdempotentRequestError(err) ||
HTTP_STATUS_TO_RETRY_ON.includes(err.response?.status ?? 0),
retryDelay: (retryCount, error) => {
if (error?.response?.status === 429) {
const retryAfterMs = getRetryAfterMs(error)
if (retryAfterMs !== undefined) {
return retryAfterMs
}
}
return Math.max(retryCount, 1) * 1000
},
}
+62
View File
@@ -0,0 +1,62 @@
import * as client from '@botpress/client'
import { Logger } from '../logger'
import { SafeOmit, Merge } from '../utils/type-utils'
import { ApiClient } from './client'
export type ApiClientProps = {
apiUrl: string
token: string
workspaceId: string
botId?: string
extraHeaders?: Record<string, string>
}
export type ApiClientFactory = {
newClient: (props: ApiClientProps, logger: Logger) => ApiClient
}
export type PublicOrUnlistedIntegration = client.Integration & { visibility: 'public' | 'unlisted' }
export type PrivateIntegration = client.Integration & { workspaceId: string }
export type PublicOrPrivateIntegration = client.Integration & { workspaceId?: string }
export type IntegrationSummary = (
| client.ClientOutputs['listIntegrations']
| client.ClientOutputs['listPublicIntegrations']
)['integrations'][number]
export type BotSummary = client.ClientOutputs['listBots']['bots'][number]
export type PublicInterface = client.Interface & { public: true }
export type PrivateInterface = client.Interface & { workspaceId: string }
export type PublicOrPrivateInterface = client.Interface & { workspaceId?: string }
export type PublicPlugin = client.Plugin & { public: true }
export type PrivatePlugin = client.Plugin & { workspaceId: string }
export type PublicOrPrivatePlugin = client.Plugin & { workspaceId?: string }
export type CreateBotRequestBody = client.ClientInputs['createBot']
export type UpdateBotRequestBody = client.ClientInputs['updateBot']
export type CreateIntegrationRequestBody = client.ClientInputs['createIntegration']
export type UpdateIntegrationRequestBody = client.ClientInputs['updateIntegration']
export type CreateInterfaceRequestBody = client.ClientInputs['createInterface']
export type UpdateInterfaceRequestBody = client.ClientInputs['updateInterface']
type PluginDependency = client.Plugin['dependencies']['integrations'][string]
export type CreatePluginRequestBody = Merge<
SafeOmit<client.ClientInputs['createPlugin'], 'code'>,
{
dependencies?: {
integrations?: Record<string, PluginDependency>
interfaces?: Record<string, PluginDependency>
}
}
>
export type UpdatePluginRequestBody = Merge<
client.ClientInputs['updatePlugin'],
{
dependencies?: {
integrations?: Record<string, PluginDependency | null>
interfaces?: Record<string, PluginDependency | null>
}
}
>
+234
View File
@@ -0,0 +1,234 @@
import * as chat from '@botpress/chat'
import chalk from 'chalk'
import * as readline from 'readline'
import * as uuid from 'uuid'
import * as utils from '../utils'
type MessageSource = 'myself' | 'bot' | 'other'
type ChatMessage = chat.Message & { source: MessageSource }
type ChatState =
| {
status: 'stopped'
}
| {
status: 'running'
messages: ChatMessage[]
connection: chat.SignalListener
keyboard: readline.Interface
}
const USER_ICONS: Record<MessageSource, string> = {
myself: '👤',
bot: '🤖',
other: '👥',
}
const MESSAGE_ICONS: Record<chat.Message['payload']['type'], string> = {
audio: '🎵',
card: '🃏',
carousel: '🎠',
choice: '🔽',
dropdown: '🔽',
file: '📁',
image: '🌅',
location: '📍',
text: '',
video: '🎥',
markdown: '',
bloc: '🧱',
}
const EXIT_KEYWORDS = ['exit', '.exit']
export type ChatProps = {
client: chat.AuthenticatedClient
conversationId: string
protocol: chat.ServerEventsProtocol
}
export class Chat {
private _events = new utils.emitter.EventEmitter<{ state: ChatState }>()
private _state: ChatState = { status: 'stopped' }
public static launch(props: ChatProps): Chat {
const instance = new Chat(props)
void instance._run()
return instance
}
private constructor(private _props: ChatProps) {}
private async _run() {
this._switchAlternateScreenBuffer()
this._events.on('state', this._renderMessages)
const connection = await this._props.client.listenConversation({
id: this._props.conversationId,
protocol: this._props.protocol,
})
const keyboard = readline.createInterface({
input: process.stdin,
output: process.stdout,
})
connection.on('message_created', (m) => void this._onMessageReceived(m))
keyboard.on('line', (l) => void this._onKeyboardInput(l))
process.stdin.on('keypress', (_, key) => {
if (key.name === 'escape') {
void this._onExit()
}
})
this._setState({ status: 'running', messages: [], connection, keyboard })
}
private _setState = (newState: ChatState) => {
this._state = newState
this._events.emit('state', this._state)
}
private _onMessageReceived = async (message: chat.Signals['message_created']) => {
if (this._state.status === 'stopped') {
return
}
if (message.userId === this._props.client.user.id) {
return
}
const source: MessageSource = message.isBot ? 'bot' : 'other'
this._setState({ ...this._state, messages: [...this._state.messages, { ...message, source }] })
}
private _onKeyboardInput = async (line: string) => {
if (this._state.status === 'stopped') {
return
}
if (EXIT_KEYWORDS.includes(line)) {
await this._onExit()
return
}
if (!line) {
this._setState({ ...this._state })
return
}
const message = this._textToMessage(line)
this._setState({ ...this._state, messages: [...this._state.messages, message] })
await this._props.client.createMessage(message)
}
private _onExit = async () => {
if (this._state.status === 'stopped') {
return
}
const { connection, keyboard } = this._state
await connection.disconnect()
connection.cleanup()
keyboard.close()
this._setState({ status: 'stopped' })
this._clearStdOut()
this._restoreOriginalScreenBuffer()
}
public wait(): Promise<void> {
return new Promise<void>((resolve) => {
const cb = (state: ChatState) => {
if (state.status === 'stopped') {
this._events.off('state', cb)
resolve()
}
}
this._events.on('state', cb)
})
}
private _renderMessages = () => {
if (this._state.status === 'stopped') {
return
}
this._clearStdOut()
this._printHeader()
for (const message of this._state.messages) {
const prefix = USER_ICONS[message.source]
const text = this._messageToText(message)
const coloredText = message.source === 'bot' ? text : chalk.gray(text)
process.stdout.write(`${prefix} ${coloredText}\n`)
}
this._state.keyboard.setPrompt('>> ')
this._state.keyboard.prompt(true) // Redisplay the prompt and maintain current input
}
private _printHeader = () => {
process.stdout.write(chalk.bold('Botpress Chat\n'))
process.stdout.write(chalk.gray('Type "exit" or press ESC key to quit\n'))
}
private _switchAlternateScreenBuffer = () => {
process.stdout.write('\x1B[?1049h')
}
private _restoreOriginalScreenBuffer = () => {
process.stdout.write('\x1B[?1049l')
}
private _clearStdOut = () => {
process.stdout.write('\x1B[2J\x1B[0;0H')
}
private _messageToText = (message: Pick<chat.Message, 'payload'>): string => {
const prefix = MESSAGE_ICONS[message.payload.type]
switch (message.payload.type) {
case 'audio':
return prefix + message.payload.audioUrl
case 'card':
return prefix + JSON.stringify(message.payload)
case 'carousel':
return prefix + JSON.stringify(message.payload)
case 'choice':
return (
prefix +
[message.payload.text, ...message.payload.options.map((o) => ` - ${o.label} (${o.value})`)].join('\n')
)
case 'dropdown':
return (
prefix +
[message.payload.text, ...message.payload.options.map((o) => ` - ${o.label} (${o.value})`)].join('\n')
)
case 'file':
return prefix + message.payload.fileUrl
case 'image':
return prefix + message.payload.imageUrl
case 'location':
return prefix + `${message.payload.latitude},${message.payload.longitude} (${message.payload.address})`
case 'text':
return prefix + message.payload.text
case 'video':
return prefix + message.payload.videoUrl
case 'markdown':
return prefix + message.payload.markdown
case 'bloc':
return [
prefix,
...message.payload.items.map((item) => this._messageToText({ payload: item })).map((l) => `\t${l}`),
].join('\n')
default:
type _assertion = utils.types.AssertNever<typeof message.payload>
return '<unknown>'
}
}
private _textToMessage = (text: string): ChatMessage => {
return {
id: uuid.v4(),
userId: this._props.client.user.id,
source: 'myself',
conversationId: this._props.conversationId,
createdAt: new Date().toISOString(),
payload: { type: 'text', text },
}
}
}
+45
View File
@@ -0,0 +1,45 @@
import 'dotenv/config'
import yargs from '@bpinternal/yargs-extra'
import commandDefinitions from './command-definitions'
import commandImplementations from './command-implementations'
import * as tree from './command-tree'
import * as errors from './errors'
import { Logger } from './logger'
import { registerYargs } from './register-yargs'
const logError = (thrown: unknown) => {
const error = errors.BotpressCLIError.map(thrown)
// genuine crashes only: print the full chain so headless callers (no -v) still get the reason.
new Logger().error(errors.BotpressCLIError.fullStack(error))
}
const onError = (thrown: unknown) => {
logError(thrown)
process.exit(1)
}
const yargsFail = (msg?: string) => {
// usage errors are bad input, not crashes; show the clean message and help, never a stack.
if (msg !== undefined) {
new Logger().error(`${msg}\n`)
}
yargs.showHelp()
process.exit(1)
}
process.on('uncaughtException', (thrown: unknown) => onError(thrown))
process.on('unhandledRejection', (thrown: unknown) => onError(thrown))
const commands = tree.zipTree(commandDefinitions, commandImplementations)
registerYargs(yargs, commands)
void yargs
.version()
.scriptName('bp')
.demandCommand(1, "You didn't provide any command. Use the --help flag to see the list of available commands.")
.recommendCommands()
.strict()
.help()
.fail(yargsFail)
.parse()
@@ -0,0 +1,95 @@
import * as sdk from '@botpress/sdk'
import * as consts from '../consts'
import { Module } from '../module'
import { BotPluginsIndexModule } from './bot-plugins'
import { BotTypingsModule } from './bot-typings'
export class BotImplementationModule extends Module {
private _typingsModule: BotTypingsModule
private _pluginsModule: BotPluginsIndexModule
public constructor(bot: sdk.BotDefinition) {
super({
exportName: 'Bot',
path: consts.INDEX_FILE,
})
this._typingsModule = new BotTypingsModule(bot)
this._typingsModule.unshift('typings')
this.pushDep(this._typingsModule)
this._pluginsModule = new BotPluginsIndexModule(bot)
this._pluginsModule.unshift(consts.fromOutDir.pluginsDir)
this.pushDep(this._pluginsModule)
}
public async getContent() {
const {
//
_typingsModule: typingsModule,
_pluginsModule: pluginsModule,
} = this
const typingsImport = typingsModule.import(this)
const pluginsImport = pluginsModule.import(this)
return [
consts.GENERATED_HEADER,
'import * as sdk from "@botpress/sdk"',
`import * as ${typingsModule.name} from "./${typingsImport}"`,
`import * as ${pluginsModule.name} from "./${pluginsImport}"`,
'',
`export * from "./${typingsImport}"`,
`export * from "./${pluginsImport}"`,
'',
`type TPlugins = ${pluginsModule.name}.TPlugins`,
`type TBot = sdk.DefaultBot<${typingsModule.name}.${typingsModule.exportName}>`,
'',
"export type BotProps = Omit<sdk.BotProps<TBot, TPlugins>, 'plugins'>",
'',
'export class Bot extends sdk.Bot<TBot, TPlugins> {',
' public constructor(props: BotProps) {',
' super({',
' ...props,',
` plugins: ${pluginsModule.name}.${pluginsModule.exportName}`,
' })',
' }',
'}',
'',
'// extra types',
'',
'type AsyncFunction = (...args: any[]) => Promise<any>',
'',
'export type BotHandlers = sdk.InjectedBotHandlers<TBot>',
'',
'export type EventHandlers = Required<{',
" [K in keyof BotHandlers['eventHandlers']]: NonNullable<BotHandlers['eventHandlers'][K]>[number]",
'}>',
'export type MessageHandlers = Required<{',
" [K in keyof BotHandlers['messageHandlers']]: NonNullable<BotHandlers['messageHandlers'][K]>[number]",
'}>',
'export type WorkflowHandlers = {',
" [TWorkflowName in keyof Required<BotHandlers['workflowHandlers'][keyof BotHandlers['workflowHandlers']]>]:",
" NonNullable<Required<BotHandlers['workflowHandlers'][keyof BotHandlers['workflowHandlers']]>[TWorkflowName]>[number]",
'}',
'',
"export type MessageHandlerProps = Parameters<MessageHandlers['*']>[0]",
"export type EventHandlerProps = Parameters<EventHandlers['*']>[0]",
'export type WorkflowHandlerProps = {',
' [TWorkflowName in keyof WorkflowHandlers]: WorkflowHandlers[TWorkflowName] extends',
' (..._: infer U) => any ? U[0] : never',
'}',
'',
"export type Client = (MessageHandlerProps | EventHandlerProps)['client']",
'export type ClientOperation = keyof {',
' [K in keyof Client as Client[K] extends AsyncFunction ? K : never]: null',
'}',
'export type ClientInputs = {',
' [K in ClientOperation]: Parameters<Client[K]>[0]',
'}',
'export type ClientOutputs = {',
' [K in ClientOperation]: Awaited<ReturnType<Client[K]>>',
'}',
].join('\n')
}
}
@@ -0,0 +1,49 @@
import * as sdk from '@botpress/sdk'
import * as consts from '../../consts'
import * as mod from '../../module'
import * as strings from '../../strings'
import { BotPluginModule } from './plugin-module'
export class BotPluginsIndexModule extends mod.Module {
private _pluginModules: BotPluginModule[]
public constructor(sdkBotDefinition: sdk.BotDefinition) {
super({
path: consts.INDEX_FILE,
exportName: 'plugins',
})
const pluginsModules: BotPluginModule[] = []
for (const plugin of Object.values(sdkBotDefinition.plugins ?? {})) {
const pluginModule = new BotPluginModule(plugin)
pluginModule.unshift(pluginModule.pluginKey)
this.pushDep(pluginModule)
pluginsModules.push(pluginModule)
}
this._pluginModules = pluginsModules
}
public async getContent(): Promise<string> {
const modules = this._pluginModules.map((module) => ({
importAlias: strings.importAlias(module.name),
importFrom: module.import(this),
module,
}))
return [
consts.GENERATED_HEADER,
'import * as sdk from "@botpress/sdk"',
...modules.map(({ importAlias, importFrom }) => `import * as ${importAlias} from "./${importFrom}";`),
...modules.map(({ importAlias, importFrom }) => `export * as ${importAlias} from "./${importFrom}";`),
'',
`export const ${this.exportName} = {`,
...modules.map(({ module, importAlias }) => ` "${module.pluginKey}": ${importAlias}.${module.exportName},`),
'}',
'',
'export type TPlugins = {',
...modules.map(({ module, importAlias }) => ` "${module.pluginKey}": ${importAlias}.TPlugin;`),
'}',
].join('\n')
}
}
@@ -0,0 +1,112 @@
import * as sdk from '@botpress/sdk'
import * as consts from '../../consts'
import { Module, ModuleProps } from '../../module'
import { PluginTypingsModule } from '../../plugin-implementation/plugin-typings'
type PluginInstance = NonNullable<sdk.BotDefinition['plugins']>[string]
class BundleJsModule extends Module {
private _indexJs: string
public constructor(plugin: PluginInstance) {
super({
path: 'bundle.js',
exportName: 'default',
})
this._indexJs = plugin.implementation.toString()
}
public async getContent() {
return this._indexJs
}
}
class BundleDtsModule extends Module {
public constructor(private _typingsModule: PluginTypingsModule) {
super({
path: 'bundle.d.ts',
exportName: 'default',
})
}
public async getContent() {
const typingsImport = this._typingsModule.import(this)
return [
consts.GENERATED_HEADER,
'import * as sdk from "@botpress/sdk"',
`import * as ${this._typingsModule.name} from "./${typingsImport}"`,
`type TPlugin = sdk.DefaultPlugin<${this._typingsModule.name}.${this._typingsModule.exportName}>`,
'export default new sdk.Plugin<TPlugin>({})',
'',
].join('\n')
}
}
class PluginConfigModule extends Module {
private _plugin: PluginInstance
public constructor(config: ModuleProps & { plugin: PluginInstance }) {
super(config)
this._plugin = config.plugin
}
public async getContent() {
const { interfaces, integrations, configuration, alias } = this._plugin
const content = JSON.stringify({ alias, interfaces, integrations, configuration }, null, 2)
return `export default ${content}`
}
}
export class BotPluginModule extends Module {
private _typingsModule: PluginTypingsModule
private _bundleJsModule: BundleJsModule
private _bundleDtsModule: BundleDtsModule
private _configModule: PluginConfigModule
public readonly pluginKey: string
public constructor(plugin: PluginInstance) {
super({
exportName: 'default',
path: consts.INDEX_FILE,
})
this.pluginKey = plugin.alias ?? plugin.name
this._typingsModule = new PluginTypingsModule(plugin.definition)
this._typingsModule.unshift('typings')
this.pushDep(this._typingsModule)
this._bundleJsModule = new BundleJsModule(plugin)
this.pushDep(this._bundleJsModule)
this._bundleDtsModule = new BundleDtsModule(this._typingsModule)
this.pushDep(this._bundleDtsModule)
this._configModule = new PluginConfigModule({
path: 'config.ts',
exportName: 'default',
plugin,
})
this.pushDep(this._configModule)
}
public async getContent() {
const configImport = this._configModule.import(this)
const typingsImport = this._typingsModule.import(this)
return [
consts.GENERATED_HEADER,
'import * as sdk from "@botpress/sdk"',
'import bundle from "./bundle"',
`import * as ${this._typingsModule.name} from "./${typingsImport}"`,
`import * as ${this._configModule.name} from "./${configImport}"`,
'',
`export type TPlugin = sdk.DefaultPlugin<${this._typingsModule.name}.${this._typingsModule.exportName}>`,
'',
`export const configuration = ${this._configModule.name}.${this._configModule.exportName}.configuration`,
`export const interfaces = ${this._configModule.name}.${this._configModule.exportName}.interfaces`,
'',
`export default bundle.initialize(${this._configModule.name}.${this._configModule.exportName})`,
'',
].join('\n')
}
}
@@ -0,0 +1,54 @@
import * as sdk from '@botpress/sdk'
import { zuiSchemaToTypeScriptType } from '../../generators'
import { Module, ReExportTypeModule } from '../../module'
import * as strings from '../../strings'
type ActionInput = sdk.BotActionDefinition['input']
type ActionOutput = sdk.BotActionDefinition['output']
export class ActionInputModule extends Module {
public constructor(private _input: ActionInput) {
const name = 'input'
const exportName = strings.typeName(name)
super({ path: `${name}.ts`, exportName })
}
public async getContent() {
return zuiSchemaToTypeScriptType(this._input.schema, this.exportName)
}
}
export class ActionOutputModule extends Module {
public constructor(private _output: ActionOutput) {
const name = 'output'
const exportName = strings.typeName(name)
super({ path: `${name}.ts`, exportName })
}
public async getContent() {
return zuiSchemaToTypeScriptType(this._output.schema, this.exportName)
}
}
export class ActionModule extends ReExportTypeModule {
public constructor(actionName: string, action: sdk.BotActionDefinition) {
super({ exportName: strings.typeName(actionName) })
const inputModule = new ActionInputModule(action.input)
const outputModule = new ActionOutputModule(action.output)
this.pushDep(inputModule)
this.pushDep(outputModule)
}
}
export class ActionsModule extends ReExportTypeModule {
public constructor(actions: Record<string, sdk.BotActionDefinition>) {
super({ exportName: strings.typeName('actions') })
for (const [actionName, action] of Object.entries(actions)) {
const module = new ActionModule(actionName, action)
module.unshift(actionName)
this.pushDep(module)
}
}
}
@@ -0,0 +1,29 @@
import * as sdk from '@botpress/sdk'
import { zuiSchemaToTypeScriptType } from '../../generators'
import { Module, ReExportTypeModule } from '../../module'
import * as strings from '../../strings'
export class EventModule extends Module {
public constructor(
name: string,
private _event: sdk.BotEventDefinition
) {
const eventName = name
const exportName = strings.typeName(eventName)
super({ path: `${name}.ts`, exportName })
}
public async getContent() {
return zuiSchemaToTypeScriptType(this._event.schema, this.exportName)
}
}
export class EventsModule extends ReExportTypeModule {
public constructor(events: Record<string, sdk.BotEventDefinition>) {
super({ exportName: strings.typeName('events') })
for (const [eventName, event] of Object.entries(events)) {
const module = new EventModule(eventName, event)
this.pushDep(module)
}
}
}
@@ -0,0 +1,115 @@
import * as sdk from '@botpress/sdk'
import * as consts from '../../consts'
import { IntegrationTypingsModule } from '../../integration-implementation/integration-typings'
import { Module, ReExportTypeModule } from '../../module'
import * as strings from '../../strings'
import { ActionsModule } from './actions-module'
import { EventsModule } from './events-module'
import { StatesModule } from './states-module'
import { TablesModule } from './tables-module'
import { WorkflowsModule } from './workflows-module'
class BotIntegrationsModule extends ReExportTypeModule {
public constructor(bot: sdk.BotDefinition) {
super({
exportName: 'Integrations',
})
for (const [alias, integration] of Object.entries(bot.integrations ?? {})) {
const integrationModule = new IntegrationTypingsModule(integration.definition).setCustomTypeName(alias)
integrationModule.unshift(strings.dirName(alias))
this.pushDep(integrationModule)
}
}
}
type BotTypingsIndexDependencies = {
integrationsModule: BotIntegrationsModule
eventsModule: EventsModule
statesModule: StatesModule
actionsModule: ActionsModule
tablesModule: TablesModule
workflowsModule: WorkflowsModule
}
export class BotTypingsModule extends Module {
private _dependencies: BotTypingsIndexDependencies
public constructor(bot: sdk.BotDefinition) {
super({
exportName: 'TBot',
path: consts.INDEX_FILE,
})
const integrationsModule = new BotIntegrationsModule(bot)
integrationsModule.unshift('integrations')
this.pushDep(integrationsModule)
const eventsModule = new EventsModule(bot.withPlugins.events ?? {})
eventsModule.unshift('events')
this.pushDep(eventsModule)
const statesModule = new StatesModule(bot.withPlugins.states ?? {})
statesModule.unshift('states')
this.pushDep(statesModule)
const tablesModule = new TablesModule(bot.withPlugins.tables ?? {})
tablesModule.unshift('tables')
this.pushDep(tablesModule)
const actionsModule = new ActionsModule(bot.withPlugins.actions ?? {})
actionsModule.unshift('actions')
this.pushDep(actionsModule)
const workflowsModule = new WorkflowsModule(bot.withPlugins.workflows ?? {})
workflowsModule.unshift('workflows')
this.pushDep(workflowsModule)
this._dependencies = {
integrationsModule,
eventsModule,
statesModule,
actionsModule,
tablesModule,
workflowsModule,
}
}
public async getContent() {
const { integrationsModule, eventsModule, statesModule, actionsModule, tablesModule, workflowsModule } =
this._dependencies
const integrationsImport = integrationsModule.import(this)
const eventsImport = eventsModule.import(this)
const statesImport = statesModule.import(this)
const actionsImport = actionsModule
const tablesImport = tablesModule.import(this)
const workflowsImport = workflowsModule
return [
consts.GENERATED_HEADER,
`import * as ${integrationsModule.name} from './${integrationsImport}'`,
`import * as ${eventsModule.name} from './${eventsModule.name}'`,
`import * as ${statesModule.name} from './${statesModule.name}'`,
`import * as ${actionsModule.name} from './${actionsImport.name}'`,
`import * as ${tablesModule.name} from './${tablesImport}'`,
`import * as ${workflowsModule.name} from './${workflowsImport.name}'`,
'',
`export * as ${integrationsModule.name} from './${integrationsImport}'`,
`export * as ${eventsModule.name} from './${eventsImport}'`,
`export * as ${statesModule.name} from './${statesImport}'`,
`export * as ${actionsModule.name} from './${actionsImport.name}'`,
`export * as ${tablesModule.name} from './${tablesImport}'`,
`export * as ${workflowsModule.name} from './${workflowsImport.name}'`,
'',
'export type TBot = {',
` integrations: ${integrationsModule.name}.${integrationsModule.exportName}`,
` events: ${eventsModule.name}.${eventsModule.exportName}`,
` states: ${statesModule.name}.${statesModule.exportName}`,
` actions: ${actionsModule.name}.${actionsModule.exportName}`,
` tables: ${tablesModule.name}.${tablesModule.exportName}`,
` workflows: ${workflowsModule.name}.${workflowsModule.exportName}`,
'}',
].join('\n')
}
}
@@ -0,0 +1,62 @@
import * as sdk from '@botpress/sdk'
import * as consts from '../../consts'
import * as gen from '../../generators'
import { Module, ReExportTypeModule } from '../../module'
import * as strings from '../../strings'
export class StatePayloadModule extends Module {
public constructor(private _state: sdk.BotStateDefinition) {
super({
path: 'payload.ts',
exportName: strings.typeName('Payload'),
})
}
public async getContent() {
return gen.zuiSchemaToTypeScriptType(this._state.schema, 'Payload')
}
}
export class StateModule extends Module {
private _payloadModule: StatePayloadModule
public constructor(
private _name: string,
private _state: sdk.BotStateDefinition
) {
super({
path: consts.INDEX_FILE,
exportName: strings.typeName(_name),
})
this._payloadModule = new StatePayloadModule(_state)
this.pushDep(this._payloadModule)
}
public async getContent(): Promise<string> {
const { _payloadModule } = this
const payloadImport = _payloadModule.import(this)
const exportName = strings.typeName(this._name)
return [
consts.GENERATED_HEADER,
`import * as ${_payloadModule.name} from './${payloadImport}'`,
`export type ${exportName} = {`,
` type: ${gen.primitiveToTypescriptValue(this._state.type)},`,
` payload: ${_payloadModule.name}.${_payloadModule.exportName}`,
'}',
].join('\n')
}
}
export class StatesModule extends ReExportTypeModule {
public constructor(states: Record<string, sdk.BotStateDefinition>) {
super({ exportName: strings.typeName('states') })
for (const [stateName, state] of Object.entries(states)) {
const module = new StateModule(stateName, state)
module.unshift(stateName)
this.pushDep(module)
}
}
}
@@ -0,0 +1,30 @@
import * as sdk from '@botpress/sdk'
import { zuiSchemaToTypeScriptType } from '../../generators'
import { Module, ReExportTypeModule } from '../../module'
import * as strings from '../../strings'
export class TableModule extends Module {
public constructor(
name: string,
private _table: sdk.BotTableDefinition
) {
super({
path: `${name}.ts`,
exportName: strings.typeName(name),
})
}
public async getContent() {
return zuiSchemaToTypeScriptType(this._table.schema, this.exportName)
}
}
export class TablesModule extends ReExportTypeModule {
public constructor(tables: Record<string, sdk.BotTableDefinition>) {
super({ exportName: strings.typeName('tables') })
for (const [tableName, table] of Object.entries(tables)) {
const module = new TableModule(tableName, table)
this.pushDep(module)
}
}
}
@@ -0,0 +1,73 @@
import * as sdk from '@botpress/sdk'
import { primitiveToTypescriptValue, zuiSchemaToTypeScriptType } from '../../generators'
import { Module, ReExportTypeModule } from '../../module'
import * as strings from '../../strings'
type WorkflowInput = sdk.BotWorkflowDefinition['input']
type WorkflowOutput = sdk.BotWorkflowDefinition['output']
type WorkflowTags = sdk.BotWorkflowDefinition['tags']
export class WorkflowInputModule extends Module {
public constructor(private _input: WorkflowInput) {
const name = 'input'
const exportName = strings.typeName(name)
super({ path: `${name}.ts`, exportName })
}
public async getContent() {
return zuiSchemaToTypeScriptType(this._input.schema, this.exportName)
}
}
export class WorkflowOutputModule extends Module {
public constructor(private _output: WorkflowOutput) {
const name = 'output'
const exportName = strings.typeName(name)
super({ path: `${name}.ts`, exportName })
}
public async getContent() {
return zuiSchemaToTypeScriptType(this._output.schema, this.exportName)
}
}
export class WorkflowTagsModule extends Module {
public constructor(private _tags: WorkflowTags) {
const name = 'tags'
const exportName = strings.typeName(name)
super({ path: `${name}.ts`, exportName })
}
public async getContent() {
const tags = Object.keys(this._tags ?? {})
.map((tagName) => `${primitiveToTypescriptValue(tagName)}: string`)
.join(', ')
return `export type ${this.exportName} = { ${tags} }`
}
}
export class WorkflowModule extends ReExportTypeModule {
public constructor(workflowName: string, workflow: sdk.BotWorkflowDefinition) {
super({ exportName: strings.typeName(workflowName) })
const inputModule = new WorkflowInputModule(workflow.input)
const outputModule = new WorkflowOutputModule(workflow.output)
const tagsModule = new WorkflowTagsModule(workflow.tags)
this.pushDep(inputModule)
this.pushDep(outputModule)
this.pushDep(tagsModule)
}
}
export class WorkflowsModule extends ReExportTypeModule {
public constructor(workflows: Record<string, sdk.BotWorkflowDefinition>) {
super({ exportName: strings.typeName('workflows') })
for (const [workflowName, workflow] of Object.entries(workflows)) {
const module = new WorkflowModule(workflowName, workflow)
module.unshift(workflowName)
this.pushDep(module)
}
}
}
@@ -0,0 +1,45 @@
import * as sdk from '@botpress/sdk'
import * as consts from '../consts'
import * as mod from '../module'
import { SecretIndexModule } from '../secret-module'
import * as types from '../typings'
import { BotImplementationModule } from './bot-implementation'
class BotIndexModule extends mod.Module {
private _botImplModule: BotImplementationModule
private _botSecretModule: SecretIndexModule
public constructor(sdkBotDefinition: sdk.BotDefinition) {
super({
path: consts.INDEX_FILE,
exportName: '',
})
const botImpl = new BotImplementationModule(sdkBotDefinition)
botImpl.unshift(consts.fromOutDir.implementationDir)
this.pushDep(botImpl)
this._botImplModule = botImpl
const botSecrets = new SecretIndexModule(sdkBotDefinition.secrets ?? {})
botSecrets.unshift(consts.fromOutDir.secretsDir)
this.pushDep(botSecrets)
this._botSecretModule = botSecrets
}
public async getContent(): Promise<string> {
const botImplImport = this._botImplModule.import(this)
const botSecretImport = this._botSecretModule.import(this)
return [
//
consts.GENERATED_HEADER,
`export * from "./${botImplImport}"`,
`export * from "./${botSecretImport}"`,
].join('\n')
}
}
export const generateBotImplementation = async (sdkBotDefinition: sdk.BotDefinition): Promise<types.File[]> => {
const botIndexModule = new BotIndexModule(sdkBotDefinition)
return botIndexModule.flatten()
}
@@ -0,0 +1,10 @@
export * from '../consts'
export const GENERATED_HEADER = [
'/* eslint-disable */',
'/* tslint:disable */',
'// This file is generated. Do not edit it manually.',
'',
].join('\n')
export const INDEX_FILE = 'index.ts'
export const INDEX_DECLARATION_FILE = 'index.d.ts'
export const DEFAULT_EXPORT_NAME = 'default'
@@ -0,0 +1,67 @@
import * as sdk from '@botpress/sdk'
import { JSONSchema7 } from 'json-schema'
import _ from 'lodash'
import * as prettier from 'prettier'
import * as utils from '../utils'
import * as consts from './consts'
export type Primitive = string | number | boolean | null | undefined
export const zuiSchemaToTypeScriptType = async (zuiSchema: sdk.z.Schema, name: string): Promise<string> => {
let code = zuiSchema.toTypescriptType({ treatDefaultAsOptional: true })
code = `export type ${name} = ${code}`
code = await prettier.format(code, { parser: 'typescript' })
return [
//
consts.GENERATED_HEADER,
code,
].join('\n')
}
export const jsonSchemaToTypescriptZuiSchema = async (
schema: JSONSchema7,
name: string,
extraProps: Record<string, string> = {}
): Promise<string> => {
schema = await utils.schema.dereferenceSchema(schema)
const zuiSchema = sdk.z.transforms.fromJSONSchemaLegacy(schema)
const allProps = {
...extraProps,
schema: zuiSchema.toTypescriptSchema(),
}
let code = [
consts.GENERATED_HEADER,
'import { z } from "@botpress/sdk"',
`export const ${name} = ${typescriptValuesToRecordString(allProps)}`,
].join('\n')
code = await prettier.format(code, { parser: 'typescript' })
return code
}
export const stringifySingleLine = (x: object): string => {
return JSON.stringify(x, null, 1).replace(/\n */g, ' ')
}
export function primitiveToTypescriptValue(x: Primitive): string {
if (typeof x === 'undefined') {
return 'undefined'
}
return JSON.stringify(x)
}
export function primitiveRecordToTypescriptValues(x: Record<string, Primitive>): Record<string, string> {
return _(x)
.toPairs()
.filter(([_key, value]) => value !== undefined)
.map(([key, value]) => [key, primitiveToTypescriptValue(value)])
.fromPairs()
.value()
}
export const primitiveRecordToRecordString = (record: Record<string, Primitive>): string =>
typescriptValuesToRecordString(primitiveRecordToTypescriptValues(record))
export const typescriptValuesToRecordString = (record: Record<string, string>): string =>
['{', ...Object.entries(record).map(([key, value]) => ` ${key}: ${value},`), '}'].join('\n')
@@ -0,0 +1,8 @@
export * from './typings'
export { secretEnvVariableName } from './secret-module'
export { generateBotImplementation } from './bot-implementation'
export { generateIntegrationImplementation } from './integration-implementation'
export { generatePluginImplementation } from './plugin-implementation'
export { generateIntegrationPackage } from './integration-package'
export { generateInterfacePackage } from './interface-package'
export { generatePluginPackage } from './plugin-package'
@@ -0,0 +1,44 @@
import * as sdk from '@botpress/sdk'
import * as consts from '../consts'
import { SecretIndexModule } from '../secret-module'
import * as types from '../typings'
import { IntegrationImplementationModule } from './integration-implementation'
const generateIntegrationImplementationCls = async (
sdkIntegrationDefinition: sdk.IntegrationDefinition,
implPath: string
): Promise<types.File[]> => {
const indexModule = new IntegrationImplementationModule(sdkIntegrationDefinition)
indexModule.unshift(implPath)
return indexModule.flatten()
}
const generateIntegrationSecrets = async (
sdkIntegrationDefinition: sdk.IntegrationDefinition,
secretsPath: string
): Promise<types.File[]> => {
const indexModule = new SecretIndexModule(sdkIntegrationDefinition.secrets ?? {})
indexModule.unshift(secretsPath)
return indexModule.flatten()
}
const generateIntegrationImplementationIndex = async (implPath: string, secretsPath: string): Promise<types.File> => {
let content = ''
content += `export * from './${implPath}'\n`
content += `export * from './${secretsPath}'\n`
return {
path: consts.INDEX_FILE,
content,
}
}
export const generateIntegrationImplementation = async (
sdkIntegrationDefinition: sdk.IntegrationDefinition
): Promise<types.File[]> => {
const implPath = consts.fromOutDir.implementationDir
const secretsPath = consts.fromOutDir.secretsDir
const implFiles = await generateIntegrationImplementationCls(sdkIntegrationDefinition, implPath)
const secretFiles = await generateIntegrationSecrets(sdkIntegrationDefinition, secretsPath)
const indexFile = await generateIntegrationImplementationIndex(implPath, secretsPath)
return [...implFiles, ...secretFiles, indexFile]
}
@@ -0,0 +1,89 @@
import * as sdk from '@botpress/sdk'
import * as consts from '../consts'
import { Module } from '../module'
import { IntegrationTypingsModule } from './integration-typings'
export class IntegrationImplementationModule extends Module {
private _typingsModule: IntegrationTypingsModule
public constructor(integration: sdk.IntegrationDefinition) {
super({
path: consts.INDEX_FILE,
exportName: 'Integration',
})
this._typingsModule = new IntegrationTypingsModule(integration)
this._typingsModule.unshift('typings')
this.pushDep(this._typingsModule)
}
public async getContent() {
let content = ''
const typingsImport = this._typingsModule.import(this)
content += [
consts.GENERATED_HEADER,
'import * as sdk from "@botpress/sdk"',
`import * as ${this._typingsModule.name} from "./${typingsImport}"`,
`export * from "./${typingsImport}"`,
'',
`type TIntegration = sdk.DefaultIntegration<${this._typingsModule.name}.${this._typingsModule.exportName}>`,
'',
'export type IntegrationProps = sdk.IntegrationProps<TIntegration>',
'',
'export class Integration extends sdk.Integration<TIntegration> {}',
'',
'export type Client = sdk.IntegrationSpecificClient<TIntegration>',
'',
'// extra types',
'',
'type Cast<X, Y> = X extends Y ? X : Y',
'type ValueOf<T> = T[keyof T]',
'type AsyncFunction = (...args: any[]) => Promise<any>',
'',
"export type HandlerProps = Parameters<IntegrationProps['handler']>[0]",
'',
"export type Context = HandlerProps['ctx']",
"export type Logger = HandlerProps['logger']",
'',
'export type CommonHandlerProps = {',
' ctx: Context',
' client: Client',
' logger: Logger',
'}',
'',
'export type ActionProps = {',
" [K in keyof IntegrationProps['actions']]: Parameters<IntegrationProps['actions'][K]>[0]",
'}',
'export type AnyActionProps = ValueOf<ActionProps>',
'',
'export type MessageProps = {',
" [TChannel in keyof IntegrationProps['channels']]: {",
" [TMessage in keyof IntegrationProps['channels'][TChannel]['messages']]: Parameters<",
" IntegrationProps['channels'][TChannel]['messages'][TMessage]",
' >[0]',
' }',
'}',
'export type AnyMessageProps = ValueOf<ValueOf<MessageProps>>',
'',
'export type AckFunctions = {',
' [TChannel in keyof MessageProps]: {',
" [TMessage in keyof MessageProps[TChannel]]: Cast<MessageProps[TChannel][TMessage], AnyMessageProps>['ack']",
' }',
'}',
'export type AnyAckFunction = ValueOf<ValueOf<AckFunctions>>',
'',
'export type ClientOperation = ValueOf<{',
' [K in keyof Client as Client[K] extends AsyncFunction ? K : never]: K',
'}>',
'export type ClientRequests = {',
' [K in ClientOperation]: Parameters<Client[K]>[0]',
'}',
'export type ClientResponses = {',
' [K in ClientOperation]: Awaited<ReturnType<Client[K]>>',
'}',
].join('\n')
return content
}
}
@@ -0,0 +1,54 @@
import * as sdk from '@botpress/sdk'
import { zuiSchemaToTypeScriptType } from '../../generators'
import { Module, ReExportTypeModule } from '../../module'
import * as strings from '../../strings'
type ActionInput = sdk.ActionDefinition['input']
type ActionOutput = sdk.ActionDefinition['output']
export class ActionInputModule extends Module {
public constructor(private _input: ActionInput) {
const name = 'input'
const exportName = strings.typeName(name)
super({ path: `${name}.ts`, exportName })
}
public async getContent() {
return zuiSchemaToTypeScriptType(this._input.schema, this.exportName)
}
}
export class ActionOutputModule extends Module {
public constructor(private _output: ActionOutput) {
const name = 'output'
const exportName = strings.typeName(name)
super({ path: `${name}.ts`, exportName })
}
public async getContent() {
return zuiSchemaToTypeScriptType(this._output.schema, this.exportName)
}
}
export class ActionModule extends ReExportTypeModule {
public constructor(actionName: string, action: sdk.ActionDefinition) {
super({ exportName: strings.typeName(actionName) })
const inputModule = new ActionInputModule(action.input)
const outputModule = new ActionOutputModule(action.output)
this.pushDep(inputModule)
this.pushDep(outputModule)
}
}
export class ActionsModule extends ReExportTypeModule {
public constructor(actions: Record<string, sdk.ActionDefinition>) {
super({ exportName: strings.typeName('actions') })
for (const [actionName, action] of Object.entries(actions)) {
const module = new ActionModule(actionName, action)
module.unshift(actionName)
this.pushDep(module)
}
}
}
@@ -0,0 +1,85 @@
import * as sdk from '@botpress/sdk'
import { GENERATED_HEADER, INDEX_FILE } from '../../consts'
import { zuiSchemaToTypeScriptType, stringifySingleLine } from '../../generators'
import { Module, ReExportTypeModule } from '../../module'
import * as strings from '../../strings'
class MessageModule extends Module {
public constructor(
name: string,
private _message: sdk.MessageDefinition
) {
super({
path: `${name}.ts`,
exportName: strings.typeName(name),
})
}
public async getContent() {
return zuiSchemaToTypeScriptType(this._message.schema, this.exportName)
}
}
class MessagesModule extends ReExportTypeModule {
public constructor(channel: sdk.ChannelDefinition) {
super({ exportName: strings.typeName('messages') })
for (const [messageName, message] of Object.entries(channel.messages ?? {})) {
const module = new MessageModule(messageName, message)
this.pushDep(module)
}
}
}
class ChannelModule extends Module {
private _messagesModule: MessagesModule
public constructor(
channelName: string,
private _channel: sdk.ChannelDefinition
) {
super({
path: INDEX_FILE,
exportName: strings.typeName(channelName),
})
this._messagesModule = new MessagesModule(_channel)
this._messagesModule.unshift('messages')
this.pushDep(this._messagesModule)
}
public async getContent() {
const messageImport = this._messagesModule.import(this)
const conversation = {
tags: this._channel.conversation?.tags ?? {},
creation: this._channel.conversation?.creation ?? { enabled: false, requiredTags: [] },
}
const message = {
tags: this._channel.message?.tags ?? {},
}
return [
GENERATED_HEADER,
`import { ${this._messagesModule.exportName} } from './${messageImport}'`,
`export * from './${messageImport}'`,
'',
`export type ${this.exportName} = {`,
` messages: ${this._messagesModule.exportName}`,
` message: ${stringifySingleLine(message)}`,
` conversation: ${stringifySingleLine(conversation)}`,
'}',
].join('\n')
}
}
export class ChannelsModule extends ReExportTypeModule {
public constructor(channels: Record<string, sdk.ChannelDefinition>) {
super({ exportName: strings.typeName('channels') })
for (const [channelName, channel] of Object.entries(channels)) {
const module = new ChannelModule(channelName, channel)
module.unshift(channelName)
this.pushDep(module)
}
}
}
@@ -0,0 +1,26 @@
import * as sdk from '@botpress/sdk'
import { INDEX_FILE } from '../../consts'
import { zuiSchemaToTypeScriptType } from '../../generators'
import { Module } from '../../module'
import * as strings from '../../strings'
export class DefaultConfigurationModule extends Module {
public constructor(private _configuration: sdk.ConfigurationDefinition | undefined) {
const name = 'configuration'
const exportName = strings.typeName(name)
super({
path: INDEX_FILE,
exportName,
})
}
public async getContent() {
if (!this._configuration) {
return [
'/** Default Configuration of the Integration */',
'export type Configuration = Record<string, never>;',
].join('\n')
}
return zuiSchemaToTypeScriptType(this._configuration.schema, this.exportName)
}
}
@@ -0,0 +1,36 @@
import * as sdk from '@botpress/sdk'
import { zuiSchemaToTypeScriptType } from '../../generators'
import { Module, ReExportTypeModule } from '../../module'
import * as strings from '../../strings'
export class ConfigurationModule extends Module {
public constructor(
name: string,
private _configuration: sdk.ConfigurationDefinition
) {
const configurationName = name
const exportName = strings.typeName(`${configurationName}Config`)
super({
path: `${name}.ts`,
exportName,
})
}
public async getContent() {
const { schema } = this._configuration
if (!schema) {
return `export type ${this.exportName} = Record<string, never>;`
}
return zuiSchemaToTypeScriptType(schema, this.exportName)
}
}
export class ConfigurationsModule extends ReExportTypeModule {
public constructor(configurations: Record<string, sdk.ConfigurationDefinition>) {
super({ exportName: strings.typeName('configurations') })
for (const [configurationName, configuration] of Object.entries(configurations)) {
const module = new ConfigurationModule(configurationName, configuration)
this.pushDep(module)
}
}
}
@@ -0,0 +1,30 @@
import * as sdk from '@botpress/sdk'
import { zuiSchemaToTypeScriptType } from '../../generators'
import { Module, ReExportTypeModule } from '../../module'
import * as strings from '../../strings'
export class EntityModule extends Module {
public constructor(
name: string,
private _entity: sdk.EntityDefinition
) {
const entityName = name
const exportName = strings.typeName(entityName)
super({ path: `${name}.ts`, exportName })
}
public async getContent() {
return zuiSchemaToTypeScriptType(this._entity.schema, this.exportName)
}
}
export class EntitiesModule extends ReExportTypeModule {
public constructor(entities: Record<string, sdk.EntityDefinition>) {
super({ exportName: strings.typeName('entities') })
for (const [entityName, entity] of Object.entries(entities)) {
const module = new EntityModule(entityName, entity)
this.pushDep(module)
}
}
}
@@ -0,0 +1,29 @@
import * as sdk from '@botpress/sdk'
import { zuiSchemaToTypeScriptType } from '../../generators'
import { Module, ReExportTypeModule } from '../../module'
import * as strings from '../../strings'
export class EventModule extends Module {
public constructor(
name: string,
private _event: sdk.EventDefinition
) {
const eventName = name
const exportName = strings.typeName(eventName)
super({ path: `${name}.ts`, exportName })
}
public async getContent() {
return zuiSchemaToTypeScriptType(this._event.schema, this.exportName)
}
}
export class EventsModule extends ReExportTypeModule {
public constructor(events: Record<string, sdk.EventDefinition>) {
super({ exportName: strings.typeName('events') })
for (const [eventName, event] of Object.entries(events)) {
const module = new EventModule(eventName, event)
this.pushDep(module)
}
}
}
@@ -0,0 +1,127 @@
import * as sdk from '@botpress/sdk'
import { GENERATED_HEADER, INDEX_FILE } from '../../consts'
import { stringifySingleLine } from '../../generators'
import { Module } from '../../module'
import { ActionsModule } from './actions-module'
import { ChannelsModule } from './channels-module'
import { DefaultConfigurationModule } from './configuration-module'
import { ConfigurationsModule } from './configurations-module'
import { EntitiesModule } from './entities-module'
import { EventsModule } from './events-module'
import { StatesModule } from './states-module'
type IntegrationTypingsModuleDependencies = {
defaultConfigModule: DefaultConfigurationModule
configurationsModule: ConfigurationsModule
actionsModule: ActionsModule
channelsModule: ChannelsModule
eventsModule: EventsModule
statesModule: StatesModule
entitiesModule: EntitiesModule
}
export class IntegrationTypingsModule extends Module {
private _dependencies: IntegrationTypingsModuleDependencies
public constructor(private _integration: sdk.IntegrationPackage['definition']) {
super({
path: INDEX_FILE,
exportName: 'TIntegration',
})
const defaultConfigModule = new DefaultConfigurationModule(_integration.configuration)
defaultConfigModule.unshift('configuration')
const configurationsModule = new ConfigurationsModule(_integration.configurations ?? {})
configurationsModule.unshift('configurations')
const actionsModule = new ActionsModule(_integration.actions ?? {})
actionsModule.unshift('actions')
const channelsModule = new ChannelsModule(_integration.channels ?? {})
channelsModule.unshift('channels')
const eventsModule = new EventsModule(_integration.events ?? {})
eventsModule.unshift('events')
const statesModule = new StatesModule(_integration.states ?? {})
statesModule.unshift('states')
const entitiesModule = new EntitiesModule(_integration.entities ?? {})
entitiesModule.unshift('entities')
this._dependencies = {
defaultConfigModule,
configurationsModule,
actionsModule,
channelsModule,
eventsModule,
statesModule,
entitiesModule,
}
for (const dep of Object.values(this._dependencies)) {
this.pushDep(dep)
}
}
public async getContent() {
let content = ''
const {
defaultConfigModule,
configurationsModule,
actionsModule,
channelsModule,
eventsModule,
statesModule,
entitiesModule,
} = this._dependencies
const defaultConfigImport = defaultConfigModule.import(this)
const configurationsImport = configurationsModule.import(this)
const actionsImport = actionsModule.import(this)
const channelsImport = channelsModule.import(this)
const eventsImport = eventsModule.import(this)
const statesImport = statesModule.import(this)
const entitiesImport = entitiesModule.import(this)
const user = {
tags: this._integration.user?.tags ?? {},
creation: this._integration.user?.creation ?? { enabled: false, requiredTags: [] },
}
content += [
GENERATED_HEADER,
`import * as ${defaultConfigModule.name} from "./${defaultConfigImport}"`,
`import * as ${configurationsModule.name} from "./${configurationsImport}"`,
`import * as ${actionsModule.name} from "./${actionsImport}"`,
`import * as ${channelsModule.name} from "./${channelsImport}"`,
`import * as ${eventsModule.name} from "./${eventsImport}"`,
`import * as ${statesModule.name} from "./${statesImport}"`,
`import * as ${entitiesModule.name} from "./${entitiesImport}"`,
`export * as ${defaultConfigModule.name} from "./${defaultConfigImport}"`,
`export * as ${configurationsModule.name} from "./${configurationsImport}"`,
`export * as ${actionsModule.name} from "./${actionsImport}"`,
`export * as ${channelsModule.name} from "./${channelsImport}"`,
`export * as ${eventsModule.name} from "./${eventsImport}"`,
`export * as ${statesModule.name} from "./${statesImport}"`,
`export * as ${entitiesModule.name} from "./${entitiesImport}"`,
'',
'export type TIntegration = {',
` name: "${this._integration.name}"`,
` version: "${this._integration.version}"`,
` user: ${stringifySingleLine(user)}`,
` configuration: ${defaultConfigModule.name}.${defaultConfigModule.exportName}`,
` configurations: ${configurationsModule.name}.${configurationsModule.exportName}`,
` actions: ${actionsModule.name}.${actionsModule.exportName}`,
` channels: ${channelsModule.name}.${channelsModule.exportName}`,
` events: ${eventsModule.name}.${eventsModule.exportName}`,
` states: ${statesModule.name}.${statesModule.exportName}`,
` entities: ${entitiesModule.name}.${entitiesModule.exportName}`,
'}',
].join('\n')
return content
}
}
@@ -0,0 +1,62 @@
import * as sdk from '@botpress/sdk'
import * as consts from '../../consts'
import * as gen from '../../generators'
import { Module, ReExportTypeModule } from '../../module'
import * as strings from '../../strings'
export class StatePayloadModule extends Module {
public constructor(private _state: sdk.StateDefinition) {
super({
path: 'payload.ts',
exportName: strings.typeName('Payload'),
})
}
public async getContent() {
return gen.zuiSchemaToTypeScriptType(this._state.schema, 'Payload')
}
}
export class StateModule extends Module {
private _payloadModule: StatePayloadModule
public constructor(
private _name: string,
private _state: sdk.StateDefinition
) {
super({
path: consts.INDEX_FILE,
exportName: strings.typeName(_name),
})
this._payloadModule = new StatePayloadModule(_state)
this.pushDep(this._payloadModule)
}
public async getContent(): Promise<string> {
const { _payloadModule } = this
const payloadImport = _payloadModule.import(this)
const exportName = strings.typeName(this._name)
return [
consts.GENERATED_HEADER,
`import * as ${_payloadModule.name} from './${payloadImport}'`,
`export type ${exportName} = {`,
` type: ${gen.primitiveToTypescriptValue(this._state.type)},`,
` payload: ${_payloadModule.name}.${_payloadModule.exportName}`,
'}',
].join('\n')
}
}
export class StatesModule extends ReExportTypeModule {
public constructor(states: Record<string, sdk.StateDefinition>) {
super({ exportName: strings.typeName('states') })
for (const [stateName, state] of Object.entries(states)) {
const module = new StateModule(stateName, state)
module.unshift(stateName)
this.pushDep(module)
}
}
}
@@ -0,0 +1,48 @@
import * as consts from '../consts'
import * as gen from '../generators'
import * as types from '../typings'
import { IntegrationPackageDefinitionModule } from './integration-package-definition'
const generateIntegrationPackageModule = (
definitionImport: string,
pkg: types.IntegrationInstallablePackage
): string => {
const id = pkg.integration.id ?? pkg.devId
const uri = pkg.path
const tsId = gen.primitiveToTypescriptValue(id)
const tsUri = gen.primitiveToTypescriptValue(uri)
const tsName = gen.primitiveToTypescriptValue(pkg.name)
const tsVersion = gen.primitiveToTypescriptValue(pkg.version)
return [
consts.GENERATED_HEADER,
'import * as sdk from "@botpress/sdk"',
'',
`import definition from "${definitionImport}"`,
'',
'export default {',
' type: "integration",',
` id: ${tsId},`,
` uri: ${tsUri},`,
` name: ${tsName},`,
` version: ${tsVersion},`,
' definition,',
'} satisfies sdk.IntegrationPackage',
].join('\n')
}
export const generateIntegrationPackage = async (pkg: types.IntegrationInstallablePackage): Promise<types.File[]> => {
const definitionDir = 'definition'
const definitionModule = new IntegrationPackageDefinitionModule(pkg.integration)
definitionModule.unshift(definitionDir)
const definitionFiles = await definitionModule.flatten()
return [
...definitionFiles,
{
path: consts.INDEX_FILE,
content: generateIntegrationPackageModule(`./${definitionDir}`, pkg),
},
]
}
@@ -0,0 +1,66 @@
import { jsonSchemaToTypescriptZuiSchema } from '../../generators'
import * as gen from '../../generators'
import { Module, ReExportVariableModule } from '../../module'
import * as strings from '../../strings'
import * as types from './typings'
type ActionInput = types.ActionDefinition['input']
type ActionOutput = types.ActionDefinition['output']
export class ActionInputModule extends Module {
public constructor(private _input: ActionInput) {
const name = 'input'
const exportName = strings.varName(name)
super({ path: `${name}.ts`, exportName })
}
public async getContent() {
return jsonSchemaToTypescriptZuiSchema(this._input.schema, this.exportName)
}
}
export class ActionOutputModule extends Module {
public constructor(private _output: ActionOutput) {
const name = 'output'
const exportName = strings.varName(name)
super({ path: `${name}.ts`, exportName })
}
public async getContent() {
return jsonSchemaToTypescriptZuiSchema(this._output.schema, this.exportName)
}
}
export class ActionModule extends ReExportVariableModule {
public constructor(actionName: string, action: types.ActionDefinition) {
super({
exportName: strings.varName(actionName),
extraProps: {
...gen.primitiveRecordToTypescriptValues({
title: action.title,
description: action.description,
billable: action.billable,
cacheable: action.cacheable,
}),
...(action.attributes ? { attributes: gen.stringifySingleLine(action.attributes) } : undefined),
},
})
const inputModule = new ActionInputModule(action.input)
const outputModule = new ActionOutputModule(action.output)
this.pushDep(inputModule)
this.pushDep(outputModule)
}
}
export class ActionsModule extends ReExportVariableModule {
public constructor(actions: Record<string, types.ActionDefinition>) {
super({ exportName: strings.varName('actions') })
for (const [actionName, action] of Object.entries(actions)) {
const module = new ActionModule(actionName, action)
module.unshift(actionName)
this.pushDep(module)
}
}
}
@@ -0,0 +1,95 @@
import { GENERATED_HEADER, INDEX_FILE } from '../../consts'
import { jsonSchemaToTypescriptZuiSchema, stringifySingleLine } from '../../generators'
import * as gen from '../../generators'
import { Module, ReExportVariableModule } from '../../module'
import * as strings from '../../strings'
import * as types from './typings'
class MessageModule extends Module {
public constructor(
name: string,
private _message: types.MessageDefinition
) {
super({
path: `${name}.ts`,
exportName: strings.varName(name),
})
}
public async getContent() {
return jsonSchemaToTypescriptZuiSchema(
this._message.schema,
this.exportName,
gen.primitiveRecordToTypescriptValues({
title: this._message.title,
description: this._message.description,
})
)
}
}
class MessagesModule extends ReExportVariableModule {
public constructor(channel: types.ChannelDefinition) {
super({ exportName: strings.varName('messages') })
for (const [messageName, message] of Object.entries(channel.messages ?? {})) {
const module = new MessageModule(messageName, message)
this.pushDep(module)
}
}
}
class ChannelModule extends Module {
private _messagesModule: MessagesModule
public constructor(
channelName: string,
private _channel: types.ChannelDefinition
) {
super({
path: INDEX_FILE,
exportName: strings.varName(channelName),
})
this._messagesModule = new MessagesModule(_channel)
this._messagesModule.unshift('messages')
this.pushDep(this._messagesModule)
}
public async getContent() {
const messageImport = this._messagesModule.import(this)
const conversation = {
tags: this._channel.conversation?.tags ?? {},
creation: this._channel.conversation?.creation ?? { enabled: false, requiredTags: [] },
}
const message = {
tags: this._channel.message?.tags ?? {},
}
return [
GENERATED_HEADER,
`import { ${this._messagesModule.exportName} } from './${messageImport}'`,
`export * from './${messageImport}'`,
'',
`export const ${this.exportName} = {`,
` title: ${gen.primitiveToTypescriptValue(this._channel.title)},`,
` description: ${gen.primitiveToTypescriptValue(this._channel.description)},`,
` messages: ${this._messagesModule.exportName},`,
` message: ${stringifySingleLine(message)},`,
` conversation: ${stringifySingleLine(conversation)},`,
'}',
].join('\n')
}
}
export class ChannelsModule extends ReExportVariableModule {
public constructor(channels: Record<string, types.ChannelDefinition>) {
super({ exportName: strings.varName('channels') })
for (const [channelName, channel] of Object.entries(channels)) {
const module = new ChannelModule(channelName, channel)
module.unshift(channelName)
this.pushDep(module)
}
}
}
@@ -0,0 +1,30 @@
import { JSONSchema7 } from 'json-schema'
import { INDEX_FILE } from '../../consts'
import { jsonSchemaToTypescriptZuiSchema } from '../../generators'
import * as gen from '../../generators'
import { Module } from '../../module'
import * as strings from '../../strings'
import * as types from './typings'
export class DefaultConfigurationModule extends Module {
public constructor(private _configuration: types.ConfigurationDefinition) {
const name = 'configuration'
const exportName = strings.varName(name)
super({
path: INDEX_FILE,
exportName,
})
}
public async getContent() {
const schema: JSONSchema7 = this._configuration.schema ?? { type: 'object', properties: {} }
return jsonSchemaToTypescriptZuiSchema(
schema,
this.exportName,
gen.primitiveRecordToTypescriptValues({
title: this._configuration.title,
description: this._configuration.description,
})
)
}
}
@@ -0,0 +1,42 @@
import { JSONSchema7 } from 'json-schema'
import { jsonSchemaToTypescriptZuiSchema } from '../../generators'
import * as gen from '../../generators'
import { Module, ReExportVariableModule } from '../../module'
import * as strings from '../../strings'
import * as types from './typings'
export class ConfigurationModule extends Module {
public constructor(
name: string,
private _configuration: types.ConfigurationDefinition
) {
const configurationName = name
const exportName = strings.varName(`${configurationName}Config`)
super({
path: `${name}.ts`,
exportName,
})
}
public async getContent() {
const schema: JSONSchema7 = this._configuration.schema ?? { type: 'object', properties: {} }
return jsonSchemaToTypescriptZuiSchema(
schema,
this.exportName,
gen.primitiveRecordToTypescriptValues({
title: this._configuration.title,
description: this._configuration.description,
})
)
}
}
export class ConfigurationsModule extends ReExportVariableModule {
public constructor(configurations: Record<string, types.ConfigurationDefinition>) {
super({ exportName: strings.varName('configurations') })
for (const [configurationName, configuration] of Object.entries(configurations)) {
const module = new ConfigurationModule(configurationName, configuration)
this.pushDep(module)
}
}
}
@@ -0,0 +1,38 @@
import { jsonSchemaToTypescriptZuiSchema } from '../../generators'
import * as gen from '../../generators'
import { Module, ReExportVariableModule } from '../../module'
import * as strings from '../../strings'
import * as types from './typings'
export class EntityModule extends Module {
public constructor(
name: string,
private _entity: types.EntityDefinition
) {
const entityName = name
const exportName = strings.varName(entityName)
super({ path: `${name}.ts`, exportName })
}
public async getContent() {
return jsonSchemaToTypescriptZuiSchema(
this._entity.schema,
this.exportName,
gen.primitiveRecordToTypescriptValues({
title: this._entity.title,
description: this._entity.description,
})
)
}
}
export class EntitiesModule extends ReExportVariableModule {
public constructor(entities: Record<string, types.EntityDefinition>) {
super({ exportName: strings.varName('entities') })
for (const [entityName, entity] of Object.entries(entities)) {
const module = new EntityModule(entityName, entity)
this.pushDep(module)
}
}
}
@@ -0,0 +1,36 @@
import { jsonSchemaToTypescriptZuiSchema } from '../../generators'
import * as gen from '../../generators'
import { Module, ReExportVariableModule } from '../../module'
import * as strings from '../../strings'
import * as types from './typings'
export class EventModule extends Module {
public constructor(
name: string,
private _event: types.EventDefinition
) {
const eventName = name
const exportName = strings.varName(eventName)
super({ path: `${name}.ts`, exportName })
}
public async getContent() {
return jsonSchemaToTypescriptZuiSchema(this._event.schema, this.exportName, {
...gen.primitiveRecordToTypescriptValues({
title: this._event.title,
description: this._event.description,
}),
...(this._event.attributes ? { attributes: gen.stringifySingleLine(this._event.attributes) } : undefined),
})
}
}
export class EventsModule extends ReExportVariableModule {
public constructor(events: Record<string, types.EventDefinition>) {
super({ exportName: strings.varName('events') })
for (const [eventName, event] of Object.entries(events)) {
const module = new EventModule(eventName, event)
this.pushDep(module)
}
}
}
@@ -0,0 +1,145 @@
import * as consts from '../../consts'
import { stringifySingleLine } from '../../generators'
import { Module } from '../../module'
import { ActionsModule } from './actions-module'
import { ChannelsModule } from './channels-module'
import { DefaultConfigurationModule } from './configuration-module'
import { ConfigurationsModule } from './configurations-module'
import { EntitiesModule } from './entities-module'
import { EventsModule } from './events-module'
import { InterfacesModule } from './interfaces-module'
import { StatesModule } from './states-module'
import * as types from './typings'
type IntegrationPackageModuleDependencies = {
defaultConfigModule: DefaultConfigurationModule
configurationsModule: ConfigurationsModule
actionsModule: ActionsModule
channelsModule: ChannelsModule
eventsModule: EventsModule
statesModule: StatesModule
entitiesModule: EntitiesModule
interfacesModule: InterfacesModule
}
export class IntegrationPackageDefinitionModule extends Module {
private _dependencies: IntegrationPackageModuleDependencies
public constructor(private _integration: types.IntegrationDefinition) {
super({
path: consts.INDEX_FILE,
exportName: consts.DEFAULT_EXPORT_NAME,
})
const defaultConfigModule = new DefaultConfigurationModule(_integration.configuration ?? {})
defaultConfigModule.unshift('configuration')
const configurationsModule = new ConfigurationsModule(_integration.configurations ?? {})
configurationsModule.unshift('configurations')
const actionsModule = new ActionsModule(_integration.actions ?? {})
actionsModule.unshift('actions')
const channelsModule = new ChannelsModule(_integration.channels ?? {})
channelsModule.unshift('channels')
const eventsModule = new EventsModule(_integration.events ?? {})
eventsModule.unshift('events')
const statesModule = new StatesModule(_integration.states ?? {})
statesModule.unshift('states')
const entitiesModule = new EntitiesModule(_integration.entities ?? {})
entitiesModule.unshift('entities')
const interfacesModule = new InterfacesModule(_integration.interfaces ?? {})
interfacesModule.unshift('interfaces')
this._dependencies = {
defaultConfigModule,
configurationsModule,
actionsModule,
channelsModule,
eventsModule,
statesModule,
entitiesModule,
interfacesModule,
}
for (const dep of Object.values(this._dependencies)) {
this.pushDep(dep)
}
}
public async getContent() {
let content = ''
const {
defaultConfigModule,
configurationsModule,
actionsModule,
channelsModule,
eventsModule,
statesModule,
entitiesModule,
interfacesModule,
} = this._dependencies
const defaultConfigImport = defaultConfigModule.import(this)
const configurationsImport = configurationsModule.import(this)
const actionsImport = actionsModule.import(this)
const channelsImport = channelsModule.import(this)
const eventsImport = eventsModule.import(this)
const statesImport = statesModule.import(this)
const entitiesImport = entitiesModule.import(this)
const interfacesImport = interfacesModule.import(this)
const user = {
tags: this._integration.user?.tags ?? {},
creation: this._integration.user?.creation ?? { enabled: false, requiredTags: [] },
}
const integrationAttributes = this._integration.attributes
? stringifySingleLine(this._integration.attributes)
: '{}'
content += [
consts.GENERATED_HEADER,
'import * as sdk from "@botpress/sdk"',
'',
`import * as ${defaultConfigModule.name} from "./${defaultConfigImport}"`,
`import * as ${configurationsModule.name} from "./${configurationsImport}"`,
`import * as ${actionsModule.name} from "./${actionsImport}"`,
`import * as ${channelsModule.name} from "./${channelsImport}"`,
`import * as ${eventsModule.name} from "./${eventsImport}"`,
`import * as ${statesModule.name} from "./${statesImport}"`,
`import * as ${entitiesModule.name} from "./${entitiesImport}"`,
`import * as ${interfacesModule.name} from "./${interfacesImport}"`,
`export * as ${defaultConfigModule.name} from "./${defaultConfigImport}"`,
`export * as ${configurationsModule.name} from "./${configurationsImport}"`,
`export * as ${actionsModule.name} from "./${actionsImport}"`,
`export * as ${channelsModule.name} from "./${channelsImport}"`,
`export * as ${eventsModule.name} from "./${eventsImport}"`,
`export * as ${statesModule.name} from "./${statesImport}"`,
`export * as ${entitiesModule.name} from "./${entitiesImport}"`,
`export * as ${interfacesModule.name} from "./${interfacesImport}"`,
'',
'export default {',
` name: "${this._integration.name}",`,
` version: "${this._integration.version}",`,
` attributes: ${integrationAttributes},`,
` user: ${stringifySingleLine(user)},`,
` configuration: ${defaultConfigModule.name}.${defaultConfigModule.exportName},`,
` configurations: ${configurationsModule.name}.${configurationsModule.exportName},`,
` actions: ${actionsModule.name}.${actionsModule.exportName},`,
` channels: ${channelsModule.name}.${channelsModule.exportName},`,
` events: ${eventsModule.name}.${eventsModule.exportName},`,
` states: ${statesModule.name}.${statesModule.exportName},`,
` entities: ${entitiesModule.name}.${entitiesModule.exportName},`,
` interfaces: ${interfacesModule.name}.${interfacesModule.exportName},`,
'} satisfies sdk.IntegrationPackage["definition"]',
].join('\n')
return content
}
}
@@ -0,0 +1,27 @@
import { Module, ReExportVariableModule } from '../../module'
import * as strings from '../../strings'
import * as types from './typings'
export class InterfaceModule extends Module {
public constructor(
name: string,
private _interface: types.InterfaceExtension
) {
const exportName = strings.varName(name)
super({ path: `${name}.ts`, exportName })
}
public async getContent() {
return `export const ${this.exportName} = ${JSON.stringify(this._interface, null, 2)}`
}
}
export class InterfacesModule extends ReExportVariableModule {
public constructor(interfaces: Record<string, types.InterfaceExtension>) {
super({ exportName: strings.varName('interfaces') })
for (const [interfaceName, intrface] of Object.entries(interfaces)) {
const module = new InterfaceModule(interfaceName, intrface)
this.pushDep(module)
}
}
}
@@ -0,0 +1,37 @@
import { jsonSchemaToTypescriptZuiSchema } from '../../generators'
import * as gen from '../../generators'
import { Module, ReExportVariableModule } from '../../module'
import * as strings from '../../strings'
import * as types from './typings'
export class StateModule extends Module {
public constructor(
name: string,
private _state: types.StateDefinition
) {
super({
path: `${name}.ts`,
exportName: strings.varName(name),
})
}
public async getContent() {
return jsonSchemaToTypescriptZuiSchema(this._state.schema, this.exportName, {
type: `"${this._state.type}" as const`,
...gen.primitiveRecordToTypescriptValues({
title: this._state.title,
description: this._state.description,
}),
})
}
}
export class StatesModule extends ReExportVariableModule {
public constructor(states: Record<string, types.StateDefinition>) {
super({ exportName: strings.varName('states') })
for (const [stateName, state] of Object.entries(states)) {
const module = new StateModule(stateName, state)
this.pushDep(module)
}
}
}
@@ -0,0 +1,10 @@
import * as types from '../../typings'
export type IntegrationDefinition = types.IntegrationDefinition
export type ActionDefinition = NonNullable<IntegrationDefinition['actions']>[string]
export type ChannelDefinition = NonNullable<IntegrationDefinition['channels']>[string]
export type MessageDefinition = NonNullable<ChannelDefinition['messages']>[string]
export type ConfigurationDefinition = NonNullable<IntegrationDefinition['configurations']>[string]
export type EntityDefinition = NonNullable<IntegrationDefinition['entities']>[string]
export type EventDefinition = NonNullable<IntegrationDefinition['events']>[string]
export type StateDefinition = NonNullable<IntegrationDefinition['states']>[string]
export type InterfaceExtension = NonNullable<IntegrationDefinition['interfaces']>[string]
@@ -0,0 +1,4 @@
/**
* An interface has no implementation, but its typings are used by the plugin and bot implementations.
*/
export { InterfaceTypingsModule } from './integration-typings'
@@ -0,0 +1,54 @@
import * as sdk from '@botpress/sdk'
import { zuiSchemaToTypeScriptType } from '../../generators'
import { Module, ReExportTypeModule } from '../../module'
import * as strings from '../../strings'
type ActionInput = sdk.ActionDefinition['input']
type ActionOutput = sdk.ActionDefinition['output']
export class ActionInputModule extends Module {
public constructor(private _input: ActionInput) {
const name = 'input'
const exportName = strings.typeName(name)
super({ path: `${name}.ts`, exportName })
}
public async getContent() {
return zuiSchemaToTypeScriptType(this._input.schema, this.exportName)
}
}
export class ActionOutputModule extends Module {
public constructor(private _output: ActionOutput) {
const name = 'output'
const exportName = strings.typeName(name)
super({ path: `${name}.ts`, exportName })
}
public async getContent() {
return zuiSchemaToTypeScriptType(this._output.schema, this.exportName)
}
}
export class ActionModule extends ReExportTypeModule {
public constructor(actionName: string, action: sdk.ActionDefinition) {
super({ exportName: strings.typeName(actionName) })
const inputModule = new ActionInputModule(action.input)
const outputModule = new ActionOutputModule(action.output)
this.pushDep(inputModule)
this.pushDep(outputModule)
}
}
export class ActionsModule extends ReExportTypeModule {
public constructor(actions: Record<string, sdk.ActionDefinition>) {
super({ exportName: strings.typeName('actions') })
for (const [actionName, action] of Object.entries(actions)) {
const module = new ActionModule(actionName, action)
module.unshift(actionName)
this.pushDep(module)
}
}
}
@@ -0,0 +1,85 @@
import * as sdk from '@botpress/sdk'
import { GENERATED_HEADER, INDEX_FILE } from '../../consts'
import { zuiSchemaToTypeScriptType, stringifySingleLine } from '../../generators'
import { Module, ReExportTypeModule } from '../../module'
import * as strings from '../../strings'
class MessageModule extends Module {
public constructor(
name: string,
private _message: sdk.MessageDefinition
) {
super({
path: `${name}.ts`,
exportName: strings.typeName(name),
})
}
public async getContent() {
return zuiSchemaToTypeScriptType(this._message.schema, this.exportName)
}
}
class MessagesModule extends ReExportTypeModule {
public constructor(channel: sdk.ChannelDefinition) {
super({ exportName: strings.typeName('messages') })
for (const [messageName, message] of Object.entries(channel.messages ?? {})) {
const module = new MessageModule(messageName, message)
this.pushDep(module)
}
}
}
class ChannelModule extends Module {
private _messagesModule: MessagesModule
public constructor(
channelName: string,
private _channel: sdk.ChannelDefinition
) {
super({
path: INDEX_FILE,
exportName: strings.typeName(channelName),
})
this._messagesModule = new MessagesModule(_channel)
this._messagesModule.unshift('messages')
this.pushDep(this._messagesModule)
}
public async getContent() {
const messageImport = this._messagesModule.import(this)
const conversation = {
tags: this._channel.conversation?.tags ?? {},
creation: this._channel.conversation?.creation ?? { enabled: false, requiredTags: [] },
}
const message = {
tags: this._channel.message?.tags ?? {},
}
return [
GENERATED_HEADER,
`import { ${this._messagesModule.exportName} } from './${messageImport}'`,
`export * from './${messageImport}'`,
'',
`export type ${this.exportName} = {`,
` messages: ${this._messagesModule.exportName}`,
` message: ${stringifySingleLine(message)}`,
` conversation: ${stringifySingleLine(conversation)}`,
'}',
].join('\n')
}
}
export class ChannelsModule extends ReExportTypeModule {
public constructor(channels: Record<string, sdk.ChannelDefinition>) {
super({ exportName: strings.typeName('channels') })
for (const [channelName, channel] of Object.entries(channels)) {
const module = new ChannelModule(channelName, channel)
module.unshift(channelName)
this.pushDep(module)
}
}
}
@@ -0,0 +1,30 @@
import * as sdk from '@botpress/sdk'
import { zuiSchemaToTypeScriptType } from '../../generators'
import { Module, ReExportTypeModule } from '../../module'
import * as strings from '../../strings'
export class EntityModule extends Module {
public constructor(
name: string,
private _entity: sdk.EntityDefinition
) {
const entityName = name
const exportName = strings.typeName(entityName)
super({ path: `${name}.ts`, exportName })
}
public async getContent() {
return zuiSchemaToTypeScriptType(this._entity.schema, this.exportName)
}
}
export class EntitiesModule extends ReExportTypeModule {
public constructor(entities: Record<string, sdk.EntityDefinition>) {
super({ exportName: strings.typeName('entities') })
for (const [entityName, entity] of Object.entries(entities)) {
const module = new EntityModule(entityName, entity)
this.pushDep(module)
}
}
}
@@ -0,0 +1,29 @@
import * as sdk from '@botpress/sdk'
import { zuiSchemaToTypeScriptType } from '../../generators'
import { Module, ReExportTypeModule } from '../../module'
import * as strings from '../../strings'
export class EventModule extends Module {
public constructor(
name: string,
private _event: sdk.EventDefinition
) {
const eventName = name
const exportName = strings.typeName(eventName)
super({ path: `${name}.ts`, exportName })
}
public async getContent() {
return zuiSchemaToTypeScriptType(this._event.schema, this.exportName)
}
}
export class EventsModule extends ReExportTypeModule {
public constructor(events: Record<string, sdk.EventDefinition>) {
super({ exportName: strings.typeName('events') })
for (const [eventName, event] of Object.entries(events)) {
const module = new EventModule(eventName, event)
this.pushDep(module)
}
}
}
@@ -0,0 +1,107 @@
import * as sdk from '@botpress/sdk'
import _ from 'lodash'
import { GENERATED_HEADER, INDEX_FILE } from '../../consts'
import { Module } from '../../module'
import { ActionsModule } from './actions-module'
import { ChannelsModule } from './channels-module'
import { EntitiesModule } from './entities-module'
import { EventsModule } from './events-module'
type InterfaceTypingsModuleDependencies = {
actionsModule: ActionsModule
channelsModule: ChannelsModule
eventsModule: EventsModule
entitiesModule: EntitiesModule
}
export class InterfaceTypingsModule extends Module {
private _dependencies: InterfaceTypingsModuleDependencies
public constructor(private _interface: sdk.InterfacePackage['definition']) {
super({
path: INDEX_FILE,
exportName: 'TInterface',
})
const references: Record<string, sdk.z.Schema> = _.mapValues(_interface.entities, (e) => e.schema)
type ZodObjectSchema = sdk.z.ZodObject | sdk.z.ZodRecord
const derefObject = (obj: { schema: ZodObjectSchema }) => {
return {
...obj,
schema: obj.schema.dereference(references) as ZodObjectSchema,
}
}
_interface = {
..._interface,
actions: _.mapValues(_interface.actions, (a) => ({
...a,
input: derefObject(a.input),
output: derefObject(a.output),
})),
channels: _.mapValues(_interface.channels, (c) => ({
...c,
messages: _.mapValues(c.messages, (m) => derefObject(m)),
})),
events: _.mapValues(_interface.events, (e) => derefObject(e)),
}
const actionsModule = new ActionsModule(_interface.actions ?? {})
actionsModule.unshift('actions')
const channelsModule = new ChannelsModule(_interface.channels ?? {})
channelsModule.unshift('channels')
const eventsModule = new EventsModule(_interface.events ?? {})
eventsModule.unshift('events')
const entitiesModule = new EntitiesModule(_interface.entities ?? {})
entitiesModule.unshift('entities')
this._dependencies = {
actionsModule,
channelsModule,
eventsModule,
entitiesModule,
}
for (const dep of Object.values(this._dependencies)) {
this.pushDep(dep)
}
}
public async getContent() {
let content = ''
const { actionsModule, channelsModule, eventsModule, entitiesModule } = this._dependencies
const actionsImport = actionsModule.import(this)
const channelsImport = channelsModule.import(this)
const eventsImport = eventsModule.import(this)
const entitiesImport = entitiesModule.import(this)
content += [
GENERATED_HEADER,
`import * as ${actionsModule.name} from "./${actionsImport}"`,
`import * as ${channelsModule.name} from "./${channelsImport}"`,
`import * as ${eventsModule.name} from "./${eventsImport}"`,
`import * as ${entitiesModule.name} from "./${entitiesImport}"`,
`export * as ${actionsModule.name} from "./${actionsImport}"`,
`export * as ${channelsModule.name} from "./${channelsImport}"`,
`export * as ${eventsModule.name} from "./${eventsImport}"`,
`export * as ${entitiesModule.name} from "./${entitiesImport}"`,
'',
'export type TInterface = {',
` name: "${this._interface.name}"`,
` version: "${this._interface.version}"`,
` actions: ${actionsModule.name}.${actionsModule.exportName}`,
` channels: ${channelsModule.name}.${channelsModule.exportName}`,
` events: ${eventsModule.name}.${eventsModule.exportName}`,
` entities: ${entitiesModule.name}.${entitiesModule.exportName}`,
'}',
].join('\n')
return content
}
}
@@ -0,0 +1,44 @@
import * as consts from '../consts'
import * as gen from '../generators'
import * as types from '../typings'
import { InterfacePackageDefinitionModule } from './interface-package-definition'
const generateInterfacePackageModule = (definitionImport: string, pkg: types.InterfaceInstallablePackage): string => {
const id = pkg.interface.id
const uri = pkg.path
const tsId = gen.primitiveToTypescriptValue(id)
const tsUri = gen.primitiveToTypescriptValue(uri)
const tsName = gen.primitiveToTypescriptValue(pkg.name)
const tsVersion = gen.primitiveToTypescriptValue(pkg.version)
return [
consts.GENERATED_HEADER,
'import * as sdk from "@botpress/sdk"',
'',
`import definition from "${definitionImport}"`,
'',
'export default {',
' type: "interface",',
` id: ${tsId},`,
` uri: ${tsUri},`,
` name: ${tsName},`,
` version: ${tsVersion},`,
' definition,',
'} satisfies sdk.InterfacePackage',
].join('\n')
}
export const generateInterfacePackage = async (pkg: types.InterfaceInstallablePackage): Promise<types.File[]> => {
const definitionDir = 'definition'
const definitionModule = new InterfacePackageDefinitionModule(pkg.interface)
definitionModule.unshift(definitionDir)
const definitionFiles = await definitionModule.flatten()
return [
...definitionFiles,
{
path: consts.INDEX_FILE,
content: generateInterfacePackageModule(`./${definitionDir}`, pkg),
},
]
}
@@ -0,0 +1,66 @@
import { jsonSchemaToTypescriptZuiSchema } from '../../generators'
import * as gen from '../../generators'
import { Module, ReExportVariableModule } from '../../module'
import * as strings from '../../strings'
import * as types from './typings'
type ActionInput = types.ActionDefinition['input']
type ActionOutput = types.ActionDefinition['output']
export class ActionInputModule extends Module {
public constructor(private _input: ActionInput) {
const name = 'input'
const exportName = strings.varName(name)
super({ path: `${name}.ts`, exportName })
}
public async getContent() {
return jsonSchemaToTypescriptZuiSchema(this._input.schema, this.exportName)
}
}
export class ActionOutputModule extends Module {
public constructor(private _output: ActionOutput) {
const name = 'output'
const exportName = strings.varName(name)
super({ path: `${name}.ts`, exportName })
}
public async getContent() {
return jsonSchemaToTypescriptZuiSchema(this._output.schema, this.exportName)
}
}
export class ActionModule extends ReExportVariableModule {
public constructor(actionName: string, action: types.ActionDefinition) {
super({
exportName: strings.varName(actionName),
extraProps: {
...gen.primitiveRecordToTypescriptValues({
title: action.title,
description: action.description,
billable: action.billable,
cacheable: action.cacheable,
}),
...(action.attributes ? { attributes: gen.stringifySingleLine(action.attributes) } : undefined),
},
})
const inputModule = new ActionInputModule(action.input)
const outputModule = new ActionOutputModule(action.output)
this.pushDep(inputModule)
this.pushDep(outputModule)
}
}
export class ActionsModule extends ReExportVariableModule {
public constructor(actions: Record<string, types.ActionDefinition>) {
super({ exportName: strings.varName('actions') })
for (const [actionName, action] of Object.entries(actions)) {
const module = new ActionModule(actionName, action)
module.unshift(actionName)
this.pushDep(module)
}
}
}
@@ -0,0 +1,82 @@
import { GENERATED_HEADER, INDEX_FILE } from '../../consts'
import { jsonSchemaToTypescriptZuiSchema } from '../../generators'
import * as gen from '../../generators'
import { Module, ReExportVariableModule } from '../../module'
import * as strings from '../../strings'
import * as types from './typings'
class MessageModule extends Module {
public constructor(
name: string,
private _message: types.MessageDefinition
) {
super({
path: `${name}.ts`,
exportName: strings.varName(name),
})
}
public async getContent() {
return jsonSchemaToTypescriptZuiSchema(
this._message.schema,
this.exportName,
gen.primitiveRecordToTypescriptValues({
title: this._message.title,
description: this._message.description,
})
)
}
}
class MessagesModule extends ReExportVariableModule {
public constructor(channel: types.ChannelDefinition) {
super({ exportName: strings.varName('messages') })
for (const [messageName, message] of Object.entries(channel.messages ?? {})) {
const module = new MessageModule(messageName, message)
this.pushDep(module)
}
}
}
class ChannelModule extends Module {
private _messagesModule: MessagesModule
public constructor(
channelName: string,
private _channel: types.ChannelDefinition
) {
super({
path: INDEX_FILE,
exportName: strings.varName(channelName),
})
this._messagesModule = new MessagesModule(_channel)
this._messagesModule.unshift('messages')
this.pushDep(this._messagesModule)
}
public async getContent() {
const messageImport = this._messagesModule.import(this)
return [
GENERATED_HEADER,
`import { ${this._messagesModule.exportName} } from './${messageImport}'`,
`export * from './${messageImport}'`,
'',
`export const ${this.exportName} = {`,
` messages: ${this._messagesModule.exportName},`,
'}',
].join('\n')
}
}
export class ChannelsModule extends ReExportVariableModule {
public constructor(channels: Record<string, types.ChannelDefinition>) {
super({ exportName: strings.varName('channels') })
for (const [channelName, channel] of Object.entries(channels)) {
const module = new ChannelModule(channelName, channel)
module.unshift(channelName)
this.pushDep(module)
}
}
}
@@ -0,0 +1,38 @@
import { jsonSchemaToTypescriptZuiSchema } from '../../generators'
import * as gen from '../../generators'
import { Module, ReExportVariableModule } from '../../module'
import * as strings from '../../strings'
import * as types from './typings'
export class EntityModule extends Module {
public constructor(
name: string,
private _entity: types.EntityDefinition
) {
const entityName = name
const exportName = strings.varName(entityName)
super({ path: `${name}.ts`, exportName })
}
public async getContent() {
return jsonSchemaToTypescriptZuiSchema(
this._entity.schema,
this.exportName,
gen.primitiveRecordToTypescriptValues({
title: this._entity.title,
description: this._entity.description,
})
)
}
}
export class EntitiesModule extends ReExportVariableModule {
public constructor(entities: Record<string, types.EntityDefinition>) {
super({ exportName: strings.varName('entities') })
for (const [entityName, entity] of Object.entries(entities)) {
const module = new EntityModule(entityName, entity)
this.pushDep(module)
}
}
}
@@ -0,0 +1,39 @@
import { jsonSchemaToTypescriptZuiSchema } from '../../generators'
import * as gen from '../../generators'
import { Module, ReExportVariableModule } from '../../module'
import * as strings from '../../strings'
import * as types from './typings'
export class EventModule extends Module {
public constructor(
name: string,
private _event: types.EventDefinition
) {
const eventName = name
const exportName = strings.varName(eventName)
super({ path: `${name}.ts`, exportName })
}
public async getContent() {
if (!this._event.schema) {
return `export const ${this.exportName} = z.object({});`
}
return jsonSchemaToTypescriptZuiSchema(this._event.schema, this.exportName, {
...gen.primitiveRecordToTypescriptValues({
title: this._event.title,
description: this._event.description,
}),
...(this._event.attributes ? { attributes: gen.stringifySingleLine(this._event.attributes) } : undefined),
})
}
}
export class EventsModule extends ReExportVariableModule {
public constructor(events: Record<string, types.EventDefinition>) {
super({ exportName: strings.varName('events') })
for (const [eventName, event] of Object.entries(events)) {
const module = new EventModule(eventName, event)
this.pushDep(module)
}
}
}
@@ -0,0 +1,88 @@
import * as consts from '../../consts'
import { stringifySingleLine } from '../../generators'
import { Module } from '../../module'
import { ActionsModule } from './actions-module'
import { ChannelsModule } from './channels-module'
import { EntitiesModule } from './entities-module'
import { EventsModule } from './events-module'
import * as types from './typings'
type InterfacePackageModuleDependencies = {
actionsModule: ActionsModule
channelsModule: ChannelsModule
eventsModule: EventsModule
entitiesModule: EntitiesModule
}
export class InterfacePackageDefinitionModule extends Module {
private _dependencies: InterfacePackageModuleDependencies
public constructor(private _interface: types.InterfaceDefinition) {
super({
path: consts.INDEX_FILE,
exportName: consts.DEFAULT_EXPORT_NAME,
})
const actionsModule = new ActionsModule(_interface.actions ?? {})
actionsModule.unshift('actions')
const channelsModule = new ChannelsModule(_interface.channels ?? {})
channelsModule.unshift('channels')
const eventsModule = new EventsModule(_interface.events ?? {})
eventsModule.unshift('events')
const entitiesModule = new EntitiesModule(_interface.entities ?? {})
entitiesModule.unshift('entities')
this._dependencies = {
actionsModule,
channelsModule,
eventsModule,
entitiesModule,
}
for (const dep of Object.values(this._dependencies)) {
this.pushDep(dep)
}
}
public async getContent() {
let content = ''
const { actionsModule, channelsModule, eventsModule, entitiesModule } = this._dependencies
const actionsImport = actionsModule.import(this)
const channelsImport = channelsModule.import(this)
const eventsImport = eventsModule.import(this)
const entitiesImport = entitiesModule.import(this)
const interfaceAttributes = this._interface.attributes ? stringifySingleLine(this._interface.attributes) : '{}'
content += [
consts.GENERATED_HEADER,
'import * as sdk from "@botpress/sdk"',
'',
`import * as ${actionsModule.name} from "./${actionsImport}"`,
`import * as ${channelsModule.name} from "./${channelsImport}"`,
`import * as ${eventsModule.name} from "./${eventsImport}"`,
`import * as ${entitiesModule.name} from "./${entitiesImport}"`,
`export * as ${actionsModule.name} from "./${actionsImport}"`,
`export * as ${channelsModule.name} from "./${channelsImport}"`,
`export * as ${eventsModule.name} from "./${eventsImport}"`,
`export * as ${entitiesModule.name} from "./${entitiesImport}"`,
'',
'export default {',
` name: "${this._interface.name}",`,
` version: "${this._interface.version}",`,
` attributes: ${interfaceAttributes},`,
` actions: ${actionsModule.name}.${actionsModule.exportName},`,
` channels: ${channelsModule.name}.${channelsModule.exportName},`,
` events: ${eventsModule.name}.${eventsModule.exportName},`,
` entities: ${entitiesModule.name}.${entitiesModule.exportName},`,
'} satisfies sdk.InterfacePackage["definition"]',
].join('\n')
return content
}
}
@@ -0,0 +1,7 @@
import * as types from '../../typings'
export type InterfaceDefinition = types.InterfaceDefinition
export type ActionDefinition = NonNullable<InterfaceDefinition['actions']>[string]
export type ChannelDefinition = NonNullable<InterfaceDefinition['channels']>[string]
export type MessageDefinition = NonNullable<ChannelDefinition['messages']>[string]
export type EntityDefinition = NonNullable<InterfaceDefinition['entities']>[string]
export type EventDefinition = NonNullable<InterfaceDefinition['events']>[string]
+185
View File
@@ -0,0 +1,185 @@
import { posix as pathlib } from 'path'
import * as utils from '../utils'
import * as consts from './consts'
import * as strings from './strings'
import { File } from './typings'
export type ModuleProps = {
path: string
exportName: string
}
export abstract class Module {
private _localDependencies: Module[] = []
private _customTypeName: string | undefined
public get path(): string {
return this._def.path.split(pathlib.sep).map(strings.fileName).join(pathlib.sep)
}
/**
* @returns module name (equivalent to unescaped file name without extension)
*/
public get name(): string {
const path = this._def.path
const basename = pathlib.basename(path)
if (basename === consts.INDEX_FILE || basename === consts.INDEX_DECLARATION_FILE) {
const dirPath = pathlib.dirname(path)
const dirname = pathlib.basename(dirPath)
return dirname
}
const withoutExtension = utils.path.rmExtension(basename)
return withoutExtension
}
public get isDefaultExport(): boolean {
return this._def.exportName === consts.DEFAULT_EXPORT_NAME
}
public get exportName(): string {
return this._def.exportName
}
public get deps(): Module[] {
return [...this._localDependencies]
}
public get typeName(): string {
return this._customTypeName ?? this.name
}
public get importAlias(): string {
return this.typeName.split(/\\|\//).map(strings.importAlias).join('__')
}
protected constructor(private _def: ModuleProps) {}
public abstract getContent(): Promise<string>
public setCustomTypeName(alias: string): this {
this._customTypeName = alias
return this
}
public pushDep(...dependencies: Module[]): this {
this._localDependencies.push(...dependencies)
return this
}
public unshift(...basePath: string[]): this {
this._def = {
...this._def,
path: pathlib.join(...basePath, this._def.path),
}
this._localDependencies = this._localDependencies.map((d) => d.unshift(...basePath))
return this
}
public async toFile(): Promise<File> {
return {
path: this.path,
content: await this.getContent(),
}
}
public async flatten(): Promise<File[]> {
const self = await this.toFile()
const allFiles: File[] = [self]
for (const dep of this._localDependencies) {
const depFiles = await dep.flatten()
allFiles.push(...depFiles)
}
return allFiles
}
public import(base: Module): string {
let relativePath = pathlib.relative(pathlib.dirname(base.path), this.path)
relativePath = pathlib.join('.', relativePath)
return utils.path.rmExtension(relativePath)
}
}
export class ReExportTypeModule extends Module {
protected constructor(def: { exportName: string }) {
super({
...def,
path: consts.INDEX_FILE,
})
}
public async getContent(): Promise<string> {
let content = consts.GENERATED_HEADER
for (const m of this.deps) {
const { importAlias } = m
const importFrom = m.import(this)
content += `import * as ${importAlias} from "./${importFrom}";\n`
content += `export * as ${importAlias} from "./${importFrom}";\n`
}
content += '\n'
content += `export type ${this.exportName} = {\n`
for (const { importAlias, typeName, exportName: exports } of this.deps) {
content += ` "${typeName}": ${importAlias}.${exports};\n`
}
content += '}'
content += '\n'
return content
}
}
export class ReExportVariableModule extends Module {
private _extraProps: Record<string, string> = {}
protected constructor(def: { exportName: string; extraProps?: Record<string, string> }) {
super({
...def,
path: consts.INDEX_FILE,
})
this._extraProps = def.extraProps ?? {}
}
public async getContent(): Promise<string> {
let content = consts.GENERATED_HEADER
for (const m of this.deps) {
const { importAlias } = m
const importFrom = m.import(this)
content += `import * as ${importAlias} from "./${importFrom}";\n`
content += `export * as ${importAlias} from "./${importFrom}";\n`
}
content += '\n'
const depProps: Record<string, string> = Object.fromEntries(
this.deps.map(({ name, exportName, importAlias }) => [name, `${importAlias}.${exportName}`])
)
const allProps = { ...depProps, ...this._extraProps }
content += `export const ${this.exportName} = {\n`
for (const [key, value] of Object.entries(allProps)) {
content += ` "${key}": ${value},\n`
}
content += '}'
content += '\n'
return content
}
}
export class SingleFileModule extends Module {
private _content: string
public constructor(def: ModuleProps & { content: string }) {
super(def)
this._content = def.content
}
public async getContent(): Promise<string> {
return this._content
}
}
@@ -0,0 +1,31 @@
import * as sdk from '@botpress/sdk'
import * as consts from '../consts'
import * as types from '../typings'
import { PluginImplementationModule } from './plugin-implementation'
const generatePluginImplementationCls = async (
sdkPluginDefinition: sdk.PluginDefinition,
implPath: string
): Promise<types.File[]> => {
const indexModule = new PluginImplementationModule(sdkPluginDefinition)
indexModule.unshift(implPath)
return indexModule.flatten()
}
const generatePluginIndex = async (implPath: string): Promise<types.File> => {
let content = ''
content += `export * from './${implPath}'\n`
return {
path: consts.INDEX_FILE,
content,
}
}
export const generatePluginImplementation = async (
sdkPluginDefinition: sdk.PluginDefinition
): Promise<types.File[]> => {
const implPath = consts.fromOutDir.implementationDir
const typingFiles = await generatePluginImplementationCls(sdkPluginDefinition, implPath)
const indexFile = await generatePluginIndex(implPath)
return [...typingFiles, indexFile]
}
@@ -0,0 +1,89 @@
import * as sdk from '@botpress/sdk'
import * as consts from '../consts'
import { Module } from '../module'
import { PluginTypingsModule } from './plugin-typings'
export class PluginImplementationModule extends Module {
private _typingsModule: PluginTypingsModule
public constructor(plugin: sdk.PluginDefinition) {
super({
exportName: 'Plugin',
path: consts.INDEX_FILE,
})
this._typingsModule = new PluginTypingsModule(plugin)
this._typingsModule.unshift('typings')
this.pushDep(this._typingsModule)
}
public async getContent() {
const typingsImport = this._typingsModule.import(this)
return [
consts.GENERATED_HEADER,
'import * as sdk from "@botpress/sdk"',
`import * as ${this._typingsModule.name} from "./${typingsImport}"`,
`export * from "./${typingsImport}"`,
'',
`type TPlugin = sdk.DefaultPlugin<${this._typingsModule.name}.${this._typingsModule.exportName}>`,
'',
'export class Plugin extends sdk.Plugin<TPlugin> {}',
'',
'export type PluginProps = sdk.PluginProps<TPlugin>',
'export type PluginRuntimeProps = sdk.PluginRuntimeProps<TPlugin>',
'',
'// extra types',
'',
'type ValueOf<T> = T[keyof T]',
'type AsyncFunction = (...args: any[]) => Promise<any>',
'',
'export type PluginHandlers = sdk.InjectedPluginHandlers<TPlugin>',
'',
'export type EventHandlers = Required<{',
" [K in keyof PluginHandlers['eventHandlers']]: NonNullable<PluginHandlers['eventHandlers'][K]>[number]",
'}>',
'export type MessageHandlers = Required<{',
" [K in keyof PluginHandlers['messageHandlers']]: NonNullable<PluginHandlers['messageHandlers'][K]>[number]",
'}>',
'export type HookHandlers = Required<{',
" [H in keyof PluginHandlers['hookHandlers']]: Required<{",
" [K in keyof PluginHandlers['hookHandlers'][H]]: NonNullable<PluginHandlers['hookHandlers'][H][K]>[number]",
' }>',
'}>',
'export type WorkflowHandlers = {',
" [TWorkflowName in keyof Required<PluginHandlers['workflowHandlers'][keyof PluginHandlers['workflowHandlers']]>]:",
" NonNullable<Required<PluginHandlers['workflowHandlers'][keyof PluginHandlers['workflowHandlers']]>[TWorkflowName]>[number]",
'}',
'',
"export type AnyMessageHandler = MessageHandlers['*']",
"export type AnyEventHandler = EventHandlers['*']",
"export type AnyActionHandler = ValueOf<PluginHandlers['actionHandlers']>",
'export type AnyHookHanders = {',
" [H in keyof HookHandlers]: NonNullable<HookHandlers[H]['*']>",
'}',
'',
'export type MessageHandlerProps = Parameters<AnyMessageHandler>[0]',
'export type EventHandlerProps = Parameters<AnyEventHandler>[0]',
'export type ActionHandlerProps = Parameters<AnyActionHandler>[0]',
'export type HookHandlerProps = {',
' [H in keyof AnyHookHanders]: Parameters<NonNullable<AnyHookHanders[H]>>[0]',
'}',
'export type WorkflowHandlerProps = {',
' [TWorkflowName in keyof WorkflowHandlers]: WorkflowHandlers[TWorkflowName] extends',
' (..._: infer U) => any ? U[0] : never',
'}',
'',
"export type Client = (MessageHandlerProps | EventHandlerProps)['client']",
'export type ClientOperation = keyof {',
' [K in keyof Client as Client[K] extends AsyncFunction ? K : never]: null',
'}',
'export type ClientInputs = {',
' [K in ClientOperation]: Parameters<Client[K]>[0]',
'}',
'export type ClientOutputs = {',
' [K in ClientOperation]: Awaited<ReturnType<Client[K]>>',
'}',
].join('\n')
}
}

Some files were not shown because too many files have changed in this diff Show More