chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@botpress/chat-api",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"check:type": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/json-schema": "^7.0.12",
|
||||
"@types/lodash": "^4.14.191",
|
||||
"@types/tmp": "^0.2.3",
|
||||
"esbuild": "^0.25.10",
|
||||
"openapi-types": "^12.1.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bpinternal/opapi": "1.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"zod": "^3.20.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { state } from '@botpress/api'
|
||||
import { OpenApi } from '@bpinternal/opapi'
|
||||
import { conversationSchema } from './models/conversation'
|
||||
import { eventSchema } from './models/event'
|
||||
import { messageSchema } from './models/message'
|
||||
import { userSchema } from './models/user'
|
||||
import { apiVersion } from './version'
|
||||
|
||||
export const { errors } = state
|
||||
|
||||
export const sections = {
|
||||
user: { description: '', title: 'User' },
|
||||
conversation: { description: '', title: 'Conversation' },
|
||||
message: { description: '', title: 'Message' },
|
||||
event: { description: '', title: 'Event' },
|
||||
} as const
|
||||
|
||||
export const parameters = {} as const
|
||||
|
||||
export const schemas = {
|
||||
User: { schema: userSchema, section: 'user' },
|
||||
Conversation: { schema: conversationSchema, section: 'conversation' },
|
||||
Message: { schema: messageSchema, section: 'message' },
|
||||
Event: { schema: eventSchema, section: 'event' },
|
||||
} as const
|
||||
|
||||
export type Schemas = typeof schemas
|
||||
export type Parameters = typeof parameters
|
||||
export type Sections = typeof sections
|
||||
export type ChatApi = OpenApi<keyof Schemas, keyof Parameters, keyof Sections>
|
||||
|
||||
export const chatApi = (): ChatApi =>
|
||||
new OpenApi(
|
||||
{
|
||||
metadata: {
|
||||
title: 'Chat API',
|
||||
description: 'API for the Chat Integration',
|
||||
server: 'https://chat.botpress.cloud/',
|
||||
version: apiVersion,
|
||||
},
|
||||
sections,
|
||||
schemas,
|
||||
errors,
|
||||
},
|
||||
{
|
||||
allowUnions: true,
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
import { exportZodSchemas } from '@bpinternal/opapi'
|
||||
import _ from 'lodash'
|
||||
import { chatApi } from './api'
|
||||
import { createOperations } from './operations'
|
||||
import { signalSchemas } from './signals'
|
||||
import { apiVersion } from './version'
|
||||
|
||||
export { messagePayloadSchema } from './models/message'
|
||||
|
||||
export const api = chatApi()
|
||||
const _operations = createOperations(api)
|
||||
|
||||
for (const op of _.values(_operations)) {
|
||||
api.addOperation(op)
|
||||
}
|
||||
|
||||
export const version = apiVersion
|
||||
export const signals = {
|
||||
exportSchemas: exportZodSchemas(signalSchemas),
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { schema } from '@bpinternal/opapi'
|
||||
import z from 'zod'
|
||||
|
||||
export const conversationIdSchema = schema(z.string(), {
|
||||
description: 'Identifier of the [Conversation](#schema_conversation)',
|
||||
})
|
||||
|
||||
export const conversationSchema = z.object({
|
||||
id: conversationIdSchema,
|
||||
createdAt: schema(z.date(), {
|
||||
description: 'Creation date of the [Conversation](#schema_conversation) in ISO 8601 format',
|
||||
}),
|
||||
updatedAt: schema(z.date(), {
|
||||
description: 'Updating date of the [Conversation](#schema_conversation) in ISO 8601 format',
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { schema } from '@bpinternal/opapi'
|
||||
import z from 'zod'
|
||||
|
||||
export const eventPayloadSchema = schema(z.record(z.any()), {
|
||||
description: 'Payload is the content of the custom event.',
|
||||
additionalProperties: {
|
||||
nullable: undefined, // fixes a weird issue with opapi zod to json conversion conversion
|
||||
},
|
||||
})
|
||||
|
||||
export const eventSchema = schema(
|
||||
z.object({
|
||||
id: schema(z.string(), { description: 'ID of the custom [Event](#schema_event).' }),
|
||||
createdAt: schema(z.date(), {
|
||||
description: 'Creation date of the custom [Event](#schema_event) in ISO 8601 format',
|
||||
}),
|
||||
payload: eventPayloadSchema,
|
||||
conversationId: schema(z.string(), { description: 'ID of the [Conversation](#schema_conversation).' }),
|
||||
userId: schema(z.string(), { description: 'ID of the [User](#schema_user).' }),
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
export * as conversation from './conversation'
|
||||
export * as message from './message'
|
||||
export * as user from './user'
|
||||
export * as event from './event'
|
||||
@@ -0,0 +1,131 @@
|
||||
import { schema } from '@bpinternal/opapi'
|
||||
import z from 'zod'
|
||||
|
||||
const NonEmptyString = z.string().min(1)
|
||||
|
||||
const textMessageSchema = z.object({
|
||||
type: z.literal('text'),
|
||||
text: NonEmptyString,
|
||||
})
|
||||
|
||||
const markdownMessageSchema = z.object({
|
||||
type: z.literal('markdown'),
|
||||
markdown: NonEmptyString,
|
||||
})
|
||||
|
||||
const imageMessageSchema = z.object({
|
||||
type: z.literal('image'),
|
||||
imageUrl: NonEmptyString,
|
||||
})
|
||||
|
||||
const audioMessageSchema = z.object({
|
||||
type: z.literal('audio'),
|
||||
audioUrl: NonEmptyString,
|
||||
})
|
||||
|
||||
const videoMessageSchema = z.object({
|
||||
type: z.literal('video'),
|
||||
videoUrl: NonEmptyString,
|
||||
})
|
||||
|
||||
const fileMessageSchema = z.object({
|
||||
type: z.literal('file'),
|
||||
fileUrl: NonEmptyString,
|
||||
title: NonEmptyString.optional(),
|
||||
})
|
||||
|
||||
const locationMessageSchema = z.object({
|
||||
type: z.literal('location'),
|
||||
latitude: z.number(),
|
||||
longitude: z.number(),
|
||||
address: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
})
|
||||
|
||||
const cardMessageSchema = z.object({
|
||||
type: z.literal('card'),
|
||||
title: NonEmptyString,
|
||||
subtitle: NonEmptyString.optional(),
|
||||
imageUrl: NonEmptyString.optional(),
|
||||
actions: z.array(
|
||||
z.object({
|
||||
action: z.enum(['postback', 'url', 'say']),
|
||||
label: NonEmptyString,
|
||||
value: NonEmptyString,
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
const _optionMessageSchema = z.object({
|
||||
text: NonEmptyString,
|
||||
options: z.array(
|
||||
z.object({
|
||||
label: NonEmptyString,
|
||||
value: NonEmptyString,
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
const choiceMessageSchema = _optionMessageSchema.extend({
|
||||
type: z.literal('choice'),
|
||||
})
|
||||
|
||||
const dropdownMessageSchema = _optionMessageSchema.extend({
|
||||
type: z.literal('dropdown'),
|
||||
})
|
||||
|
||||
const carouselMessageSchema = z.object({
|
||||
type: z.literal('carousel'),
|
||||
items: z.array(cardMessageSchema),
|
||||
})
|
||||
|
||||
const blocMessageSchema = z.object({
|
||||
type: z.literal('bloc'),
|
||||
items: z.array(
|
||||
z.union([
|
||||
textMessageSchema,
|
||||
markdownMessageSchema,
|
||||
imageMessageSchema,
|
||||
audioMessageSchema,
|
||||
videoMessageSchema,
|
||||
fileMessageSchema,
|
||||
locationMessageSchema,
|
||||
])
|
||||
),
|
||||
})
|
||||
|
||||
export const messagePayloadSchema = z.union([
|
||||
audioMessageSchema,
|
||||
cardMessageSchema,
|
||||
carouselMessageSchema,
|
||||
choiceMessageSchema,
|
||||
dropdownMessageSchema,
|
||||
fileMessageSchema,
|
||||
imageMessageSchema,
|
||||
locationMessageSchema,
|
||||
textMessageSchema,
|
||||
videoMessageSchema,
|
||||
markdownMessageSchema,
|
||||
blocMessageSchema,
|
||||
]) satisfies z.ZodSchema<{ type: string }> // ensures that the type field can be used to discriminate
|
||||
|
||||
export const messageSchema = schema(
|
||||
z.object({
|
||||
id: schema(z.string(), {
|
||||
description: 'Identifier of the [Message](#schema_message)',
|
||||
}),
|
||||
createdAt: schema(z.date(), {
|
||||
description: 'Creation date of the [Message](#schema_message) in ISO 8601 format',
|
||||
}),
|
||||
payload: schema(messagePayloadSchema, {
|
||||
description: 'Payload is the content type of the message.',
|
||||
}),
|
||||
userId: schema(z.string(), { description: 'ID of the [User](#schema_user)' }),
|
||||
conversationId: schema(z.string(), { description: 'ID of the [Conversation](#schema_conversation)' }),
|
||||
metadata: schema(z.record(z.any()).optional(), { description: 'Metadata of the message' }),
|
||||
}),
|
||||
{
|
||||
description:
|
||||
'The Message object represents a message in a [Conversation](#schema_conversation) for a specific [User](#schema_user).',
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
import { schema } from '@bpinternal/opapi'
|
||||
import z from 'zod'
|
||||
|
||||
export const userIdSchema = schema(z.string(), {
|
||||
description: 'Identifier of the [User](#schema_user)',
|
||||
})
|
||||
|
||||
export const userSchema = schema(
|
||||
z.object({
|
||||
id: userIdSchema,
|
||||
name: schema(z.string().optional(), {
|
||||
description: 'Name of the [User](#schema_user)',
|
||||
}),
|
||||
pictureUrl: schema(z.string().optional(), { description: 'Picture url of the [User](#schema_user)' }),
|
||||
profile: schema(z.string().optional(), {
|
||||
description: 'Custom profile data of the [User](#schema_user) encoded as a string',
|
||||
}),
|
||||
createdAt: schema(z.date(), {
|
||||
description: 'Creation date of the [User](#schema_user) in ISO 8601 format',
|
||||
}),
|
||||
updatedAt: schema(z.date(), {
|
||||
description: 'Updating date of the [User](#schema_user) in ISO 8601 format',
|
||||
}),
|
||||
}),
|
||||
{
|
||||
description:
|
||||
'The user object represents someone interacting with the bot within a specific integration. The same person interacting with a bot in slack and messenger will be represented with two different users.',
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Parameter } from '@bpinternal/opapi'
|
||||
|
||||
export const authHeaders = {
|
||||
'x-user-key': {
|
||||
in: 'header',
|
||||
type: 'string',
|
||||
description: 'Authentication Key',
|
||||
required: true,
|
||||
},
|
||||
} satisfies Record<string, Parameter>
|
||||
@@ -0,0 +1,277 @@
|
||||
import { schema } from '@bpinternal/opapi'
|
||||
import z from 'zod'
|
||||
import { conversationSchema, conversationIdSchema } from '../models/conversation'
|
||||
import { authHeaders } from './auth'
|
||||
import { pagedResponseMeta, pagingParameters } from './paging'
|
||||
import { OperationFunc } from './types'
|
||||
|
||||
const section = 'conversation' as const
|
||||
|
||||
export const getConversationOperation: OperationFunc = (api) => ({
|
||||
name: 'getConversation',
|
||||
description: 'Retrieves the [Conversation](#schema_conversation) object for a valid identifier.',
|
||||
method: 'get',
|
||||
path: '/conversations/{id}',
|
||||
parameters: {
|
||||
...authHeaders,
|
||||
id: {
|
||||
in: 'path',
|
||||
type: 'string',
|
||||
description: 'Conversation id',
|
||||
},
|
||||
},
|
||||
section,
|
||||
response: {
|
||||
description: 'Returns a [Conversation](#schema_conversation) object if a valid identifier was provided',
|
||||
schema: schema(
|
||||
z.object({
|
||||
conversation: api.getModelRef('Conversation'),
|
||||
})
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
export const createConversationOperation: OperationFunc = (api) => ({
|
||||
name: 'createConversation',
|
||||
description: 'Creates a new [Conversation](#schema_conversation)',
|
||||
method: 'post',
|
||||
path: '/conversations',
|
||||
parameters: authHeaders,
|
||||
requestBody: {
|
||||
description: 'Conversation data',
|
||||
schema: z.object({
|
||||
id: conversationIdSchema.optional(),
|
||||
}),
|
||||
},
|
||||
section,
|
||||
response: {
|
||||
status: 201,
|
||||
description: 'Returns a [Conversation](#schema_conversation)',
|
||||
schema: schema(
|
||||
z.object({
|
||||
conversation: api.getModelRef('Conversation'),
|
||||
})
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
export const getOrCreateConversationOperation: OperationFunc = (api) => ({
|
||||
name: 'getOrCreateConversation',
|
||||
description: 'Get or create a new [Conversation](#schema_conversation)',
|
||||
method: 'post',
|
||||
path: '/conversations/get-or-create',
|
||||
parameters: authHeaders,
|
||||
requestBody: {
|
||||
description: 'Conversation data',
|
||||
schema: z.object({
|
||||
id: conversationIdSchema,
|
||||
}),
|
||||
},
|
||||
section,
|
||||
response: {
|
||||
status: 201,
|
||||
description: 'Returns a [Conversation](#schema_conversation)',
|
||||
schema: schema(
|
||||
z.object({
|
||||
conversation: api.getModelRef('Conversation'),
|
||||
})
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
export const deleteConversationOperation: OperationFunc = () => ({
|
||||
name: 'deleteConversation',
|
||||
description:
|
||||
'Permanently deletes a [Conversation](#schema_conversation). It cannot be undone. Also immediately deletes corresponding [Messages](#schema_message).',
|
||||
method: 'delete',
|
||||
path: '/conversations/{id}',
|
||||
parameters: {
|
||||
...authHeaders,
|
||||
id: {
|
||||
in: 'path',
|
||||
type: 'string',
|
||||
description: 'Conversation id',
|
||||
},
|
||||
},
|
||||
section,
|
||||
response: {
|
||||
description: 'Returns the [Conversation](#schema_conversation) object that was deleted',
|
||||
schema: z.object({}),
|
||||
},
|
||||
})
|
||||
|
||||
export const listConversationsOperation: OperationFunc = () => ({
|
||||
name: 'listConversations',
|
||||
description: 'Returns a list of [Conversation](#schema_conversation) objects',
|
||||
method: 'get',
|
||||
path: '/conversations',
|
||||
parameters: {
|
||||
...authHeaders,
|
||||
...pagingParameters,
|
||||
},
|
||||
section,
|
||||
response: {
|
||||
description: 'Returns a list of [Conversation](#schema_conversation) objects',
|
||||
schema: z
|
||||
.object({
|
||||
conversations: z.array(conversationSchema),
|
||||
})
|
||||
.extend({ meta: pagedResponseMeta }),
|
||||
},
|
||||
})
|
||||
|
||||
export const listenConversationOperation: OperationFunc = () => ({
|
||||
name: 'listenConversation',
|
||||
description: 'Creates a SSE stream to receive messages and events from a conversation',
|
||||
method: 'get',
|
||||
path: '/conversations/{id}/listen',
|
||||
parameters: {
|
||||
...authHeaders,
|
||||
id: {
|
||||
in: 'path',
|
||||
type: 'string',
|
||||
description: 'Conversation id',
|
||||
},
|
||||
},
|
||||
section,
|
||||
response: {
|
||||
description: 'Returns nothing but a stream',
|
||||
schema: schema(z.object({})),
|
||||
},
|
||||
})
|
||||
|
||||
export const listMessagesOperation: OperationFunc = (api) => ({
|
||||
name: 'listMessages',
|
||||
description: "Retrieves the conversation's [Messages](#schema_message)",
|
||||
method: 'get',
|
||||
path: '/conversations/{conversationId}/messages',
|
||||
parameters: {
|
||||
...authHeaders,
|
||||
...pagingParameters,
|
||||
conversationId: {
|
||||
in: 'path',
|
||||
type: 'string',
|
||||
description: 'Conversation id',
|
||||
},
|
||||
},
|
||||
section,
|
||||
response: {
|
||||
description: 'Returns a list of [Message](#schema_message) objects',
|
||||
schema: z
|
||||
.object({
|
||||
messages: z.array(api.getModelRef('Message')),
|
||||
})
|
||||
.extend({ meta: pagedResponseMeta }),
|
||||
},
|
||||
})
|
||||
|
||||
export const addParticipantOperation: OperationFunc = (api) => ({
|
||||
name: 'addParticipant',
|
||||
description: 'Add a [Participant](#schema_user) to a [Conversation](#schema_conversation).',
|
||||
method: 'post',
|
||||
path: '/conversations/{conversationId}/participants',
|
||||
parameters: {
|
||||
...authHeaders,
|
||||
conversationId: {
|
||||
in: 'path',
|
||||
type: 'string',
|
||||
description: 'Conversation id',
|
||||
},
|
||||
},
|
||||
requestBody: {
|
||||
description: 'Participant data',
|
||||
schema: schema(
|
||||
z.object({
|
||||
userId: schema(z.string(), { description: 'User id' }),
|
||||
})
|
||||
),
|
||||
},
|
||||
section,
|
||||
response: {
|
||||
description: 'Returns the [Participant](#schema_user) object',
|
||||
schema: schema(
|
||||
z.object({
|
||||
participant: api.getModelRef('User'),
|
||||
})
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
export const removeParticipantOperation: OperationFunc = () => ({
|
||||
name: 'removeParticipant',
|
||||
description: 'Remove a [Participant](#schema_user) from a [Conversation](#schema_conversation).',
|
||||
method: 'delete',
|
||||
path: '/conversations/{conversationId}/participants/{userId}',
|
||||
parameters: {
|
||||
...authHeaders,
|
||||
conversationId: {
|
||||
in: 'path',
|
||||
type: 'string',
|
||||
description: 'Conversation id',
|
||||
},
|
||||
userId: {
|
||||
in: 'path',
|
||||
type: 'string',
|
||||
description: 'User id',
|
||||
},
|
||||
},
|
||||
section,
|
||||
response: {
|
||||
description: 'Returns an empty object',
|
||||
schema: z.object({}),
|
||||
},
|
||||
})
|
||||
|
||||
export const getParticipantOperation: OperationFunc = (api) => ({
|
||||
name: 'getParticipant',
|
||||
description: 'Retrieves a [Participant](#schema_user) from a [Conversation](#schema_conversation).',
|
||||
method: 'get',
|
||||
path: '/conversations/{conversationId}/participants/{userId}',
|
||||
parameters: {
|
||||
...authHeaders,
|
||||
conversationId: {
|
||||
in: 'path',
|
||||
type: 'string',
|
||||
description: 'Conversation id',
|
||||
},
|
||||
userId: {
|
||||
in: 'path',
|
||||
type: 'string',
|
||||
description: 'User id',
|
||||
},
|
||||
},
|
||||
section,
|
||||
response: {
|
||||
description: 'Returns the [Participant](#schema_user) object',
|
||||
schema: schema(
|
||||
z.object({
|
||||
participant: api.getModelRef('User'),
|
||||
})
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
export const listParticipantsOperation: OperationFunc = (api) => ({
|
||||
name: 'listParticipants',
|
||||
description: 'Retrieves a list of [Participants](#schema_user) for a given [Conversation](#schema_conversation).',
|
||||
method: 'get',
|
||||
path: '/conversations/{conversationId}/participants',
|
||||
parameters: {
|
||||
...authHeaders,
|
||||
...pagingParameters,
|
||||
conversationId: {
|
||||
in: 'path',
|
||||
type: 'string',
|
||||
description: 'Conversation id',
|
||||
},
|
||||
},
|
||||
section,
|
||||
response: {
|
||||
description: 'Returns a list of [Participants](#schema_user) objects',
|
||||
schema: z
|
||||
.object({
|
||||
participants: z.array(api.getModelRef('User')),
|
||||
})
|
||||
.extend({ meta: pagedResponseMeta }),
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { schema } from '@bpinternal/opapi'
|
||||
import z from 'zod'
|
||||
import { eventPayloadSchema } from '../models/event'
|
||||
import { authHeaders } from './auth'
|
||||
import { OperationFunc } from './types'
|
||||
|
||||
const section = 'event' as const
|
||||
|
||||
export const getEventOperation: OperationFunc = (api) => ({
|
||||
name: 'getEvent',
|
||||
description: 'Retrieves the [Event](#schema_event) object for a valid identifier.',
|
||||
method: 'get',
|
||||
path: '/events/{id}',
|
||||
parameters: {
|
||||
...authHeaders,
|
||||
id: {
|
||||
in: 'path',
|
||||
type: 'string',
|
||||
description: 'Id of the Event',
|
||||
},
|
||||
},
|
||||
section,
|
||||
response: {
|
||||
description: 'Returns an [Event](#schema_event) object if a valid identifier was provided',
|
||||
schema: schema(
|
||||
z.object({
|
||||
event: api.getModelRef('Event'),
|
||||
})
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
export const createEventOperation: OperationFunc = (api) => ({
|
||||
name: 'createEvent',
|
||||
description: 'Creates a custom [Event](#schema_event)',
|
||||
method: 'post',
|
||||
path: '/events',
|
||||
parameters: authHeaders,
|
||||
requestBody: {
|
||||
description: 'Event data',
|
||||
schema: schema(
|
||||
z.object({
|
||||
payload: eventPayloadSchema,
|
||||
conversationId: schema(z.string(), {
|
||||
description: 'ID of the [Conversation](#schema_conversation)',
|
||||
}),
|
||||
})
|
||||
),
|
||||
},
|
||||
section,
|
||||
response: {
|
||||
description: 'Returns a [Event](#schema_event).',
|
||||
status: 201,
|
||||
schema: schema(
|
||||
z.object({
|
||||
event: api.getModelRef('Event'),
|
||||
})
|
||||
),
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import _ from 'lodash'
|
||||
import { ChatApi } from '../api'
|
||||
import * as conversation from './conversation'
|
||||
import * as event from './event'
|
||||
import * as message from './message'
|
||||
import * as user from './user'
|
||||
|
||||
export * as conversation from './conversation'
|
||||
export * as message from './message'
|
||||
export * as user from './user'
|
||||
export * as event from './event'
|
||||
|
||||
type ValueOf<T> = T[keyof T]
|
||||
export type OperationFunction = ValueOf<typeof conversation> | ValueOf<typeof message> | ValueOf<typeof user>
|
||||
|
||||
export type Operation = ReturnType<OperationFunction>
|
||||
|
||||
const operationFunctions = { ...conversation, ...message, ...user, ...event }
|
||||
export const createOperations = (api: ChatApi) => _.mapValues(operationFunctions, (fn) => fn(api))
|
||||
@@ -0,0 +1,83 @@
|
||||
import { schema } from '@bpinternal/opapi'
|
||||
import z from 'zod'
|
||||
import { messagePayloadSchema } from '../models/message'
|
||||
import { authHeaders } from './auth'
|
||||
import { OperationFunc } from './types'
|
||||
|
||||
const section = 'message' as const
|
||||
|
||||
export const getMessageOperation: OperationFunc = (api) => ({
|
||||
name: 'getMessage',
|
||||
description: 'Retrieves the [Message](#schema_message) object for a valid identifier.',
|
||||
method: 'get',
|
||||
path: '/messages/{id}',
|
||||
parameters: {
|
||||
...authHeaders,
|
||||
id: {
|
||||
in: 'path',
|
||||
type: 'string',
|
||||
description: 'Id of the Message',
|
||||
},
|
||||
},
|
||||
section,
|
||||
response: {
|
||||
description: 'Returns a [Message](#schema_message) object if a valid identifier was provided',
|
||||
schema: schema(
|
||||
z.object({
|
||||
message: api.getModelRef('Message'),
|
||||
})
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
export const createMessageOperation: OperationFunc = (api) => ({
|
||||
name: 'createMessage',
|
||||
description: 'Creates a new [Message](#schema_message)',
|
||||
method: 'post',
|
||||
path: '/messages',
|
||||
parameters: authHeaders,
|
||||
requestBody: {
|
||||
description: 'Message data',
|
||||
schema: z.object({
|
||||
payload: schema(messagePayloadSchema, {
|
||||
description: 'Payload is the content type of the message.',
|
||||
}),
|
||||
conversationId: schema(z.string(), {
|
||||
description: 'ID of the [Conversation](#schema_conversation)',
|
||||
}),
|
||||
metadata: schema(z.record(z.any()).optional(), {
|
||||
description: 'Metadata of the message',
|
||||
}),
|
||||
}),
|
||||
},
|
||||
section,
|
||||
response: {
|
||||
description: 'Returns a [Message](#schema_message).',
|
||||
status: 201,
|
||||
schema: schema(
|
||||
z.object({
|
||||
message: api.getModelRef('Message'),
|
||||
})
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
export const deleteMessageOperation: OperationFunc = () => ({
|
||||
name: 'deleteMessage',
|
||||
description: 'Permanently deletes a [Message](#schema_message). It cannot be undone.',
|
||||
method: 'delete',
|
||||
path: '/messages/{id}',
|
||||
parameters: {
|
||||
...authHeaders,
|
||||
id: {
|
||||
in: 'path',
|
||||
type: 'string',
|
||||
description: 'Message id',
|
||||
},
|
||||
},
|
||||
section,
|
||||
response: {
|
||||
description: 'Returns the [Message](#schema_message) object that was deleted',
|
||||
schema: z.object({}),
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ParametersMap } from '@bpinternal/opapi'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const pagingParameters: ParametersMap = {
|
||||
nextToken: {
|
||||
in: 'query',
|
||||
description:
|
||||
'Provide the `meta.nextToken` value provided in the last API response to retrieve the next page of results',
|
||||
type: 'string',
|
||||
required: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const pagedResponseMeta = z.object({
|
||||
nextToken: z
|
||||
.string()
|
||||
.describe(
|
||||
'The token to use to retrieve the next page of results, passed as a query string parameter (value should be URL-encoded) to this API endpoint.'
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
import { ChatApi } from '../api'
|
||||
|
||||
export type Operation = Parameters<ChatApi['addOperation']>[0]
|
||||
export type OperationFunc = (api: ChatApi) => Operation
|
||||
@@ -0,0 +1,113 @@
|
||||
import { schema } from '@bpinternal/opapi'
|
||||
import z from 'zod'
|
||||
import { userIdSchema } from '../models/user'
|
||||
import { authHeaders } from './auth'
|
||||
import { OperationFunc } from './types'
|
||||
|
||||
const section = 'user' as const
|
||||
|
||||
const userInput = schema(
|
||||
z.object({
|
||||
name: schema(z.string().optional(), {
|
||||
description: 'Name of the [User](#schema_user) (not a unique identifier)',
|
||||
}),
|
||||
pictureUrl: schema(z.string().optional(), {
|
||||
description: 'Picture url of the [User](#schema_user)',
|
||||
}),
|
||||
profile: schema(z.string().max(1000).optional(), {
|
||||
description: 'Custom profile data of the [User](#schema_user) encoded as a string',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
export const getUserOperation: OperationFunc = (api) => ({
|
||||
name: 'getUser',
|
||||
description: 'Retrieves the [User](#schema_user) object for a valid identifier.',
|
||||
method: 'get',
|
||||
path: '/users/me',
|
||||
parameters: authHeaders,
|
||||
section,
|
||||
response: {
|
||||
description: 'Returns a [User](#schema_user) object if a valid identifier was provided',
|
||||
schema: schema(
|
||||
z.object({
|
||||
user: api.getModelRef('User'),
|
||||
})
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
export const createUserOperation: OperationFunc = (api) => ({
|
||||
name: 'createUser',
|
||||
description: "Creates a new [User](#schema_user). This operation can't be called when using manual authentication.",
|
||||
method: 'post',
|
||||
path: '/users',
|
||||
requestBody: {
|
||||
description: 'User data',
|
||||
schema: userInput.extend({ id: userIdSchema.optional() }),
|
||||
},
|
||||
section,
|
||||
response: {
|
||||
description: 'Returns a [User](#schema_user)',
|
||||
status: 201,
|
||||
schema: z.object({
|
||||
user: api.getModelRef('User'),
|
||||
key: z.string(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export const getOrCreateUserOperation: OperationFunc = (api) => ({
|
||||
name: 'getOrCreateUser',
|
||||
description: 'Get or create a new [User](#schema_user)',
|
||||
method: 'post',
|
||||
path: '/users/get-or-create',
|
||||
requestBody: {
|
||||
description: 'User data',
|
||||
schema: userInput,
|
||||
},
|
||||
parameters: {
|
||||
...authHeaders,
|
||||
},
|
||||
section,
|
||||
response: {
|
||||
description: 'Returns a [User](#schema_user)',
|
||||
status: 201,
|
||||
schema: z.object({
|
||||
user: api.getModelRef('User'),
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export const updateUserOperation: OperationFunc = (api) => ({
|
||||
name: 'updateUser',
|
||||
description: 'Update [User](#schema_user)',
|
||||
method: 'put',
|
||||
path: '/users/me',
|
||||
parameters: authHeaders,
|
||||
requestBody: {
|
||||
description: 'User data',
|
||||
schema: userInput,
|
||||
},
|
||||
section,
|
||||
response: {
|
||||
description: 'Returns a [User](#schema_user)',
|
||||
status: 200,
|
||||
schema: z.object({
|
||||
user: api.getModelRef('User'),
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export const deleteUserOperation: OperationFunc = () => ({
|
||||
name: 'deleteUser',
|
||||
description: 'Permanently deletes a [User](#schema_user). It cannot be undone.',
|
||||
method: 'delete',
|
||||
path: '/users/me',
|
||||
parameters: authHeaders,
|
||||
section,
|
||||
response: {
|
||||
description: 'Returns the [User](#schema_user) object that was deleted',
|
||||
schema: z.object({}),
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { z } from 'zod'
|
||||
import { message, event } from './models'
|
||||
|
||||
export const signalSchemas = {
|
||||
messageCreated: z.object({
|
||||
type: z.literal('message_created'),
|
||||
data: message.messageSchema.extend({
|
||||
isBot: z.boolean().describe('Whether the message was created by the bot or not'),
|
||||
}),
|
||||
}),
|
||||
eventCreated: z.object({
|
||||
type: z.literal('event_created'),
|
||||
data: event.eventSchema.omit({ id: true }).extend({
|
||||
id: z.string().nullable(),
|
||||
isBot: z.boolean().describe('Whether the event was created by the bot or not'),
|
||||
}),
|
||||
}),
|
||||
participantAdded: z.object({
|
||||
type: z.literal('participant_added'),
|
||||
data: z.object({
|
||||
conversationId: z.string(),
|
||||
participantId: z.string(),
|
||||
}),
|
||||
}),
|
||||
participantRemoved: z.object({
|
||||
type: z.literal('participant_removed'),
|
||||
data: z.object({
|
||||
conversationId: z.string(),
|
||||
participantId: z.string(),
|
||||
}),
|
||||
}),
|
||||
messageDeleted: z.object({
|
||||
type: z.literal('message_deleted'),
|
||||
data: z.object({
|
||||
id: z.string(),
|
||||
conversationId: z.string(),
|
||||
userId: z.string(),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const apiVersion = '1.0.0' // do not bump, tied to a single ECS service
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*", "openapi.ts", "package.json", "gen-api.ts"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
src/gen/
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
src/
|
||||
tsconfig.json
|
||||
build.ts
|
||||
openapi.ts
|
||||
readme.md
|
||||
*.tsbuildinfo
|
||||
.ignore.me.*
|
||||
@@ -0,0 +1,48 @@
|
||||
import esbuild from 'esbuild'
|
||||
import { polyfillNode } from 'esbuild-plugin-polyfill-node'
|
||||
import { dependencies } from './package.json'
|
||||
|
||||
const external = Object.keys(dependencies)
|
||||
const entryPoint = './src/index.ts'
|
||||
|
||||
const buildNode = async () =>
|
||||
esbuild.build({
|
||||
entryPoints: [entryPoint],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
external,
|
||||
outfile: './dist/index.cjs',
|
||||
sourcemap: true,
|
||||
})
|
||||
|
||||
const buildBrowser = async () =>
|
||||
esbuild.build({
|
||||
entryPoints: [entryPoint],
|
||||
bundle: true,
|
||||
platform: 'browser',
|
||||
format: 'esm',
|
||||
outfile: './dist/index.mjs',
|
||||
sourcemap: true,
|
||||
plugins: [polyfillNode({ polyfills: { crypto: true } })],
|
||||
})
|
||||
|
||||
const main = async (argv: string[]) => {
|
||||
if (argv.includes('--node')) {
|
||||
return buildNode()
|
||||
}
|
||||
if (argv.includes('--browser')) {
|
||||
return buildBrowser()
|
||||
}
|
||||
throw new Error('Please specify --node, --browser, or --bundle')
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2))
|
||||
.then(() => {
|
||||
console.info('Done')
|
||||
process.exit(0)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
const _configKeys = ['API_URL', 'ENCRYPTION_KEY'] as const
|
||||
|
||||
const values: Partial<Record<ConfigKey, string>> = {}
|
||||
|
||||
export type ConfigKey = (typeof _configKeys)[number]
|
||||
export const get = (key: ConfigKey): string => {
|
||||
const cached = values[key]
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const value = process.env[key]
|
||||
if (!value) {
|
||||
throw new Error(`${key} not set`)
|
||||
}
|
||||
|
||||
values[key] = value
|
||||
console.info(`config: ${key}=${value}`)
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { expect, test } from 'vitest'
|
||||
import _ from 'lodash'
|
||||
import * as utils from './utils'
|
||||
import * as config from './config'
|
||||
import * as chat from '../src'
|
||||
|
||||
const apiUrl = config.get('API_URL')
|
||||
|
||||
test('api prevents creating conversation with an invalid fid', async () => {
|
||||
const client = await chat.Client.connect({ apiUrl })
|
||||
|
||||
const convFid = utils.getConversationFid()
|
||||
const invalidUserIds = [
|
||||
`invalid+${convFid}`, // invalid character
|
||||
`invalid_${convFid}_${convFid}_${convFid}`, // too long
|
||||
]
|
||||
|
||||
for (const id of invalidUserIds) {
|
||||
const promise = client.createConversation({ id })
|
||||
await expect(promise).rejects.toThrow(chat.InvalidPayloadError)
|
||||
}
|
||||
|
||||
for (const id of invalidUserIds) {
|
||||
const promise = client.getOrCreateConversation({ id })
|
||||
await expect(promise).rejects.toThrow(chat.InvalidPayloadError)
|
||||
}
|
||||
})
|
||||
|
||||
test('api prevents creating multiple conversations with the same foreign id', async () => {
|
||||
const client = await chat.Client.connect({ apiUrl })
|
||||
|
||||
const conversationFid = utils.getConversationFid()
|
||||
|
||||
const createConversation = () => client.createConversation({ id: conversationFid })
|
||||
|
||||
await createConversation()
|
||||
await expect(createConversation).rejects.toThrow(chat.AlreadyExistsError)
|
||||
})
|
||||
|
||||
test('get or create a conversation always returns the same conversation', async () => {
|
||||
const client = await chat.Client.connect({ apiUrl })
|
||||
|
||||
const {
|
||||
conversation: { id: conversationId },
|
||||
} = await client.createConversation({})
|
||||
|
||||
const getOrCreate = () => client.getConversation({ id: conversationId }).then((r) => r.conversation.id)
|
||||
|
||||
expect(await getOrCreate()).toBe(conversationId)
|
||||
expect(await getOrCreate()).toBe(conversationId) // operation is idempotent
|
||||
})
|
||||
|
||||
test('get or create a conversation with a fid always returns the same conversation', async () => {
|
||||
const client = await chat.Client.connect({ apiUrl })
|
||||
|
||||
const conversationFid = utils.getConversationFid()
|
||||
await client.createConversation({ id: conversationFid })
|
||||
|
||||
const getOrCreate = () => client.getConversation({ id: conversationFid }).then((r) => r.conversation.id)
|
||||
|
||||
expect(await getOrCreate()).toBe(conversationFid)
|
||||
expect(await getOrCreate()).toBe(conversationFid) // operation is idempotent
|
||||
})
|
||||
|
||||
test('get conversation is only allowed for participants', async () => {
|
||||
const client = new chat.Client({ apiUrl })
|
||||
|
||||
const user1 = await client.createUser({})
|
||||
const user2 = await client.createUser({})
|
||||
const user3 = await client.createUser({})
|
||||
|
||||
const { conversation } = await client.createConversation({ 'x-user-key': user1.key })
|
||||
await client.addParticipant({ conversationId: conversation.id, 'x-user-key': user1.key, userId: user2.user.id })
|
||||
|
||||
const promise1 = client.getConversation({ id: conversation.id, 'x-user-key': user1.key })
|
||||
await expect(promise1).resolves.toBeTruthy()
|
||||
const promise2 = client.getConversation({ id: conversation.id, 'x-user-key': user2.key })
|
||||
await expect(promise2).resolves.toBeTruthy()
|
||||
const promise3 = client.getConversation({ id: conversation.id, 'x-user-key': user3.key })
|
||||
await expect(promise3).rejects.toThrow(chat.ForbiddenError)
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { expect, test } from 'vitest'
|
||||
import _ from 'lodash'
|
||||
import * as config from './config'
|
||||
import * as chat from '../src'
|
||||
|
||||
const apiUrl = config.get('API_URL')
|
||||
|
||||
test('api allows sending and receiving custom events', async () => {
|
||||
const client = await chat.Client.connect({ apiUrl, debug: true })
|
||||
|
||||
const {
|
||||
conversation: { id: conversationId },
|
||||
} = await client.createConversation({})
|
||||
|
||||
const listener = await client.listenConversation({
|
||||
id: conversationId,
|
||||
})
|
||||
|
||||
const waitForEventPromise = new Promise<chat.Signals['event_created']>((resolve) => {
|
||||
listener.onceOrMore('event_created', (ev) => {
|
||||
if (ev.userId === client.user.id) {
|
||||
return 'keep-listening'
|
||||
}
|
||||
resolve(ev)
|
||||
return 'stop-listening'
|
||||
})
|
||||
})
|
||||
|
||||
const createEventRequest: chat.AuthenticatedClientRequests['createEvent'] = {
|
||||
conversationId: conversationId,
|
||||
payload: {
|
||||
foo: 'bar',
|
||||
},
|
||||
}
|
||||
|
||||
const createEventPromise = client.createEvent(createEventRequest).then((res) => res.event)
|
||||
|
||||
const [eventReceived, eventSent] = await Promise.all([waitForEventPromise, createEventPromise])
|
||||
|
||||
const { event: eventFetched } = await client.getEvent({
|
||||
id: eventSent.id,
|
||||
})
|
||||
|
||||
expect(eventFetched).toEqual(eventSent)
|
||||
expect(eventReceived.payload).toEqual(eventSent.payload) // hello bot just passes the payload through
|
||||
})
|
||||
@@ -0,0 +1,265 @@
|
||||
import { expect, test } from 'vitest'
|
||||
import _ from 'lodash'
|
||||
import * as utils from './utils'
|
||||
import * as config from './config'
|
||||
import * as chat from '../src'
|
||||
|
||||
const apiUrl = config.get('API_URL')
|
||||
const encryptionKey = config.get('ENCRYPTION_KEY')
|
||||
const serverEventsProtocols = ['sse', 'websocket'] as const
|
||||
|
||||
type CheckApiCanSendAndReceiveMessagesProps = {
|
||||
client: chat.AuthenticatedClient
|
||||
conversationId: string
|
||||
protocol: chat.ServerEventsProtocol
|
||||
}
|
||||
|
||||
type MessagePayload = chat.AuthenticatedClientRequests['createMessage']['payload']
|
||||
|
||||
const checkApiCanSendAndReceiveMessages = async (
|
||||
props: CheckApiCanSendAndReceiveMessagesProps,
|
||||
payload: MessagePayload
|
||||
): Promise<MessagePayload> => {
|
||||
const { client, conversationId } = props
|
||||
|
||||
const listener = await client.listenConversation({
|
||||
id: conversationId,
|
||||
protocol: props.protocol,
|
||||
})
|
||||
|
||||
const waitForResponsePromise = new Promise<chat.Signals['message_created']>((resolve) => {
|
||||
listener.onceOrMore('message_created', (ev) => {
|
||||
if (ev.userId === client.user.id) {
|
||||
return 'keep-listening'
|
||||
}
|
||||
resolve(ev)
|
||||
return 'stop-listening'
|
||||
})
|
||||
})
|
||||
|
||||
const createMessageRequest: chat.AuthenticatedClientRequests['createMessage'] = {
|
||||
conversationId: conversationId,
|
||||
payload,
|
||||
}
|
||||
|
||||
const createMessagePromise = client.createMessage(createMessageRequest).then((res) => res.message)
|
||||
|
||||
const [{ isBot, ...messageReceived }, messageSent] = await Promise.all([waitForResponsePromise, createMessagePromise])
|
||||
|
||||
const { messages } = await client
|
||||
.listMessages({
|
||||
conversationId,
|
||||
})
|
||||
.then(({ messages }) => ({
|
||||
messages: _.sortBy(messages, (m) => new Date(m.createdAt).getTime()),
|
||||
}))
|
||||
|
||||
expect(messages.length).toBe(2)
|
||||
expect(messages[0]).toEqual(messageSent)
|
||||
expect(messages[1]).toEqual(messageReceived)
|
||||
|
||||
return messageReceived.payload
|
||||
}
|
||||
|
||||
test.each(serverEventsProtocols)('api allows sending and receiving messages using botpress IDs', async (protocol) => {
|
||||
const client = await chat.Client.connect({ apiUrl })
|
||||
|
||||
const {
|
||||
conversation: { id: conversationId },
|
||||
} = await client.createConversation({})
|
||||
|
||||
await checkApiCanSendAndReceiveMessages(
|
||||
{
|
||||
client,
|
||||
conversationId,
|
||||
protocol,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'hello world',
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.each(serverEventsProtocols)('api allows sending and receiving messages using foreign IDs', async (protocol) => {
|
||||
const userId = utils.getUserFid()
|
||||
const conversationId = utils.getConversationFid()
|
||||
const client = await chat.Client.connect({ apiUrl, userId })
|
||||
|
||||
await client.createConversation({ id: conversationId })
|
||||
|
||||
await checkApiCanSendAndReceiveMessages(
|
||||
{
|
||||
client,
|
||||
conversationId,
|
||||
protocol,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'hello world',
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.each(serverEventsProtocols)(
|
||||
'api allows sending and receiving messages using remotly generated JWTs',
|
||||
async (protocol) => {
|
||||
const userId = utils.getUserFid()
|
||||
const conversationId = utils.getConversationFid()
|
||||
|
||||
const client = await chat.Client.connect({ apiUrl, userId, encryptionKey })
|
||||
|
||||
await client.getOrCreateConversation({ id: conversationId })
|
||||
|
||||
await checkApiCanSendAndReceiveMessages(
|
||||
{
|
||||
client,
|
||||
conversationId,
|
||||
protocol,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'hello world',
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test.each(serverEventsProtocols)('api allows deleting a message', async (protocol) => {
|
||||
const client = await chat.Client.connect({ apiUrl })
|
||||
const { conversation } = await client.createConversation({})
|
||||
|
||||
const signalListener = await client.listenConversation({ id: conversation.id, protocol })
|
||||
|
||||
const [{ isBot, ...createdMessage }] = await Promise.all([
|
||||
utils.waitFor(signalListener, 'message_created'),
|
||||
client.createMessage({
|
||||
conversationId: conversation.id,
|
||||
payload: {
|
||||
type: 'text',
|
||||
text: 'hello world',
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const { message: fetchedMessage } = await client.getMessage({
|
||||
id: createdMessage.id,
|
||||
})
|
||||
|
||||
expect(fetchedMessage).toEqual(createdMessage)
|
||||
|
||||
const [deletedMessage] = await Promise.all([
|
||||
utils.waitFor(signalListener, 'message_deleted'),
|
||||
client.deleteMessage({
|
||||
id: createdMessage.id,
|
||||
}),
|
||||
])
|
||||
|
||||
expect(deletedMessage.id).toEqual(createdMessage.id)
|
||||
|
||||
await expect(
|
||||
client.getMessage({
|
||||
id: createdMessage.id,
|
||||
})
|
||||
).rejects.toThrow(chat.ResourceNotFoundError)
|
||||
})
|
||||
|
||||
test.each(serverEventsProtocols)('api allows sending and receiving messages with metadata', async (protocol) => {
|
||||
type Message = Awaited<ReturnType<chat.Client['listMessages']>>['messages'][number]
|
||||
const metadata = { foo: 'bar' }
|
||||
|
||||
const client = await chat.Client.connect({ apiUrl })
|
||||
|
||||
const { conversation } = await client.createConversation({})
|
||||
const { id: conversationId } = conversation
|
||||
|
||||
const sendMessagePromise = client.createMessage({
|
||||
conversationId,
|
||||
payload: { type: 'text', text: 'metadata' }, // hello-bot forwards metadata when message is 'metadata'
|
||||
metadata,
|
||||
})
|
||||
|
||||
const listener = await client.listenConversation({ id: conversationId, protocol })
|
||||
|
||||
const receiveSelfMessagePromise = new Promise<Message>((resolve) =>
|
||||
listener.on('message_created', (m) => {
|
||||
if (m.userId === client.user.id) {
|
||||
resolve(m)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const receiveBotMessagePromise = new Promise<Message>((resolve) =>
|
||||
listener.on('message_created', (m) => {
|
||||
if (m.userId !== client.user.id) {
|
||||
resolve(m)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const [sentMessage, receivedSelfMessage, receivedBotMessage] = await Promise.all([
|
||||
sendMessagePromise,
|
||||
receiveSelfMessagePromise,
|
||||
receiveBotMessagePromise,
|
||||
])
|
||||
|
||||
expect(sentMessage.message.metadata).toEqual(metadata)
|
||||
expect(receivedSelfMessage.metadata).toEqual(metadata)
|
||||
expect(receivedBotMessage.metadata).toEqual(metadata)
|
||||
expect(sentMessage.message.payload).not.toHaveProperty('metadata')
|
||||
expect(receivedSelfMessage.payload).not.toHaveProperty('metadata')
|
||||
expect(receivedBotMessage.payload).not.toHaveProperty('metadata')
|
||||
|
||||
const fetchedMessages = await client.listMessages({ conversationId }).then((r) => r.messages)
|
||||
|
||||
const fetchedSelfMessages = fetchedMessages.filter((m) => m.userId === client.user.id)
|
||||
expect(fetchedSelfMessages.length).toBe(1)
|
||||
|
||||
const [fetchedSelfMessage] = fetchedSelfMessages
|
||||
expect(fetchedSelfMessage!.metadata).toEqual(metadata)
|
||||
})
|
||||
|
||||
test.each(serverEventsProtocols)('api allows sending bloc messages', async (protocol) => {
|
||||
const client = await chat.Client.connect({ apiUrl })
|
||||
|
||||
const {
|
||||
conversation: { id: conversationId },
|
||||
} = await client.createConversation({})
|
||||
|
||||
await checkApiCanSendAndReceiveMessages(
|
||||
{
|
||||
client,
|
||||
conversationId,
|
||||
protocol,
|
||||
},
|
||||
{
|
||||
type: 'bloc',
|
||||
items: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'hello world',
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
imageUrl: 'https://fastly.picsum.photos/id/329/200/300.jpg?hmac=_yLyj0EqdpQ-cX84OlMxz3YzOjjd7liq6b25ldkVSpA',
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.each(serverEventsProtocols)('api allows receiving bloc messages from bot', async (protocol) => {
|
||||
const client = await chat.Client.connect({ apiUrl })
|
||||
|
||||
const {
|
||||
conversation: { id: conversationId },
|
||||
} = await client.createConversation({})
|
||||
|
||||
const responsePayload: MessagePayload = await checkApiCanSendAndReceiveMessages(
|
||||
{ client, conversationId, protocol },
|
||||
{ type: 'text', text: 'bloc' }
|
||||
)
|
||||
|
||||
expect(responsePayload.type).toEqual('bloc')
|
||||
expect(responsePayload.items.length).toEqual(3)
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
import { expect, test } from 'vitest'
|
||||
import _ from 'lodash'
|
||||
import * as utils from './utils'
|
||||
import * as config from './config'
|
||||
import * as chat from '../src'
|
||||
|
||||
const apiUrl = config.get('API_URL')
|
||||
|
||||
const protocols = ['sse', 'websocket'] as const
|
||||
|
||||
test.each(protocols)('api allows adding and removing conversation participants', async (protocol) => {
|
||||
const client = new chat.Client({ apiUrl })
|
||||
|
||||
const { key: userKey1 } = await client.createUser({})
|
||||
const { user: user2, key: userKey2 } = await client.createUser({})
|
||||
|
||||
const { conversation } = await client.createConversation({ 'x-user-key': userKey1 })
|
||||
|
||||
const listener = await client.listenConversation({ id: conversation.id, 'x-user-key': userKey1, protocol })
|
||||
|
||||
await expect(
|
||||
client.createMessage({
|
||||
'x-user-key': userKey2,
|
||||
conversationId: conversation.id,
|
||||
payload: {
|
||||
type: 'text',
|
||||
text: 'hello world',
|
||||
},
|
||||
})
|
||||
).rejects.toThrow(chat.ForbiddenError)
|
||||
|
||||
await Promise.all([
|
||||
utils.waitFor(listener, 'participant_added'),
|
||||
client.addParticipant({
|
||||
'x-user-key': userKey1,
|
||||
conversationId: conversation.id,
|
||||
userId: user2.id,
|
||||
}),
|
||||
])
|
||||
|
||||
await expect(
|
||||
client.createMessage({
|
||||
'x-user-key': userKey2,
|
||||
conversationId: conversation.id,
|
||||
payload: {
|
||||
type: 'text',
|
||||
text: 'hello world',
|
||||
},
|
||||
})
|
||||
).resolves.toBeTruthy()
|
||||
|
||||
await Promise.all([
|
||||
utils.waitFor(listener, 'participant_removed'),
|
||||
client.removeParticipant({
|
||||
'x-user-key': userKey1,
|
||||
conversationId: conversation.id,
|
||||
userId: user2.id,
|
||||
}),
|
||||
])
|
||||
|
||||
await expect(
|
||||
client.createMessage({
|
||||
'x-user-key': userKey2,
|
||||
conversationId: conversation.id,
|
||||
payload: {
|
||||
type: 'text',
|
||||
text: 'hello world',
|
||||
},
|
||||
})
|
||||
).rejects.toThrow(chat.ForbiddenError)
|
||||
})
|
||||
|
||||
test('api forbids non-participants from listing participants', async () => {
|
||||
const client = new chat.Client({ apiUrl })
|
||||
|
||||
const user1 = await client.createUser({})
|
||||
const user2 = await client.createUser({})
|
||||
const user3 = await client.createUser({})
|
||||
|
||||
const { conversation } = await client.createConversation({ 'x-user-key': user1.key })
|
||||
await client.addParticipant({ 'x-user-key': user1.key, conversationId: conversation.id, userId: user2.user.id })
|
||||
|
||||
const promise1 = client.listParticipants({ 'x-user-key': user1.key, conversationId: conversation.id })
|
||||
await expect(promise1).resolves.toBeTruthy()
|
||||
const promise2 = client.listParticipants({ 'x-user-key': user2.key, conversationId: conversation.id })
|
||||
await expect(promise2).resolves.toBeTruthy()
|
||||
const promise3 = client.listParticipants({ 'x-user-key': user3.key, conversationId: conversation.id })
|
||||
await expect(promise3).rejects.toThrow(chat.ForbiddenError)
|
||||
})
|
||||
|
||||
test.each(protocols)('signal listener is disconnected when participant is removed', async (protocol) => {
|
||||
const client = new chat.Client({ apiUrl })
|
||||
|
||||
const { user: user1, key: userKey1 } = await client.createUser({})
|
||||
const { user: user2, key: userKey2 } = await client.createUser({})
|
||||
|
||||
const { conversation } = await client.createConversation({ 'x-user-key': userKey1 })
|
||||
await client.addParticipant({ 'x-user-key': userKey1, conversationId: conversation.id, userId: user2.id })
|
||||
|
||||
const listener1 = await client.listenConversation({ id: conversation.id, 'x-user-key': userKey1, protocol })
|
||||
const listener2 = await client.listenConversation({ id: conversation.id, 'x-user-key': userKey2, protocol })
|
||||
|
||||
const messages1: chat.Signals['message_created'][] = []
|
||||
const messages2: chat.Signals['message_created'][] = []
|
||||
|
||||
listener1.on('message_created', (message) => messages1.push(message))
|
||||
listener2.on('message_created', (message) => messages2.push(message))
|
||||
|
||||
await Promise.all([
|
||||
utils.waitFor(listener1, 'message_created'),
|
||||
client.createMessage({
|
||||
'x-user-key': userKey1,
|
||||
conversationId: conversation.id,
|
||||
payload: {
|
||||
type: 'text',
|
||||
text: 'foo',
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
await client.removeParticipant({ 'x-user-key': userKey1, conversationId: conversation.id, userId: user2.id })
|
||||
|
||||
await Promise.all([
|
||||
utils.waitFor(listener1, 'message_created'),
|
||||
client.createMessage({
|
||||
'x-user-key': userKey1,
|
||||
conversationId: conversation.id,
|
||||
payload: {
|
||||
type: 'text',
|
||||
text: 'bar',
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const incomingMessages1 = messages1.filter((m) => !m.isBot)
|
||||
const incomingMessages2 = messages2.filter((m) => !m.isBot)
|
||||
expect(incomingMessages1.length).toBe(2)
|
||||
expect(incomingMessages2.length).toBe(1)
|
||||
|
||||
await listener1.disconnect()
|
||||
listener1.cleanup()
|
||||
|
||||
await listener2.disconnect()
|
||||
listener2.cleanup()
|
||||
})
|
||||
|
||||
test('api forbids removing owner from conversation participants', async () => {
|
||||
const client = new chat.Client({ apiUrl })
|
||||
|
||||
const { user: user1, key: userKey1 } = await client.createUser({})
|
||||
|
||||
const { conversation } = await client.createConversation({ 'x-user-key': userKey1 })
|
||||
|
||||
await expect(
|
||||
client.removeParticipant({
|
||||
'x-user-key': userKey1,
|
||||
conversationId: conversation.id,
|
||||
userId: user1.id,
|
||||
})
|
||||
).rejects.toThrow(chat.InvalidPayloadError)
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { expect, test } from 'vitest'
|
||||
import _ from 'lodash'
|
||||
import * as utils from './utils'
|
||||
import * as config from './config'
|
||||
import * as chat from '../src'
|
||||
|
||||
const apiUrl = config.get('API_URL')
|
||||
|
||||
test('api prevents creating user with an invalid fid', async () => {
|
||||
const client = new chat.Client({ apiUrl })
|
||||
|
||||
const userFid = utils.getUserFid()
|
||||
const invalidUserIds = [
|
||||
`invalid+${userFid}`, // invalid character
|
||||
`invalid_${userFid}_${userFid}_${userFid}_${userFid}`, // too long
|
||||
]
|
||||
|
||||
for (const id of invalidUserIds) {
|
||||
await expect(client.createUser({ id })).rejects.toThrow(chat.InvalidPayloadError)
|
||||
}
|
||||
|
||||
for (const id of invalidUserIds) {
|
||||
const key = await utils.getUserKey(id)
|
||||
await expect(client.getOrCreateUser({ 'x-user-key': key })).rejects.toThrow(chat.InvalidPayloadError)
|
||||
}
|
||||
})
|
||||
|
||||
test('api prevents creating multiple users with the same foreign id', async () => {
|
||||
const client = new chat.Client({ apiUrl })
|
||||
|
||||
const userFid = utils.getUserFid()
|
||||
|
||||
const createUser = () =>
|
||||
client.createUser({
|
||||
id: userFid,
|
||||
})
|
||||
|
||||
await createUser()
|
||||
await expect(createUser).rejects.toThrow(chat.AlreadyExistsError)
|
||||
})
|
||||
|
||||
test('get or create user should create a user', async () => {
|
||||
const client = new chat.Client({ apiUrl })
|
||||
|
||||
const userFid = utils.getUserFid()
|
||||
const userKey = await utils.getUserKey(userFid)
|
||||
await client.getOrCreateUser({ 'x-user-key': userKey })
|
||||
|
||||
const {
|
||||
user: { id: userId },
|
||||
} = await client.getOrCreateUser({ 'x-user-key': userKey })
|
||||
|
||||
expect(userId).toBeDefined()
|
||||
})
|
||||
|
||||
test('get or create user always returns the same user', async () => {
|
||||
const client = new chat.Client({ apiUrl })
|
||||
|
||||
const { key: userKey } = await client.createUser({})
|
||||
|
||||
const {
|
||||
user: { id: userId },
|
||||
} = await client.getUser({
|
||||
'x-user-key': userKey,
|
||||
})
|
||||
|
||||
const getOrCreate = () =>
|
||||
client
|
||||
.getOrCreateUser({
|
||||
'x-user-key': userKey,
|
||||
})
|
||||
.then((r) => r.user.id)
|
||||
|
||||
expect(await getOrCreate()).toBe(userId)
|
||||
expect(await getOrCreate()).toBe(userId) // operation is idempotent
|
||||
})
|
||||
|
||||
test('get or create user with a fid always returns the same user', async () => {
|
||||
const client = new chat.Client({ apiUrl })
|
||||
|
||||
const userFid = utils.getUserFid()
|
||||
const userKey = await utils.getUserKey(userFid)
|
||||
await client.createUser({ id: userFid })
|
||||
|
||||
const {
|
||||
user: { id: userId },
|
||||
} = await client.getUser({
|
||||
'x-user-key': userKey,
|
||||
})
|
||||
|
||||
const getOrCreate = () =>
|
||||
client
|
||||
.getOrCreateUser({
|
||||
'x-user-key': userKey,
|
||||
})
|
||||
.then((r) => r.user.id)
|
||||
|
||||
expect(await getOrCreate()).toBe(userId)
|
||||
expect(await getOrCreate()).toBe(userId) // operation is idempotent
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as uuid from 'uuid'
|
||||
import * as chat from '../src'
|
||||
import { signJwt } from '../src/jwt'
|
||||
import * as config from './config'
|
||||
|
||||
const encryptionKey = config.get('ENCRYPTION_KEY')
|
||||
|
||||
export const halfId = () => {
|
||||
const id = uuid.v4()
|
||||
return id.slice(0, id.length / 2)
|
||||
}
|
||||
export const getUserFid = (): string => `test-user-${halfId()}`
|
||||
export const getConversationFid = (): string => `test-conversation-${halfId()}`
|
||||
export const getUserKey = (userId: string): Promise<string> => signJwt({ id: userId }, encryptionKey)
|
||||
|
||||
export const waitFor = <S extends keyof chat.Signals>(
|
||||
listener: chat.SignalListener,
|
||||
signal: S
|
||||
): Promise<chat.Signals[S]> =>
|
||||
new Promise((resolve, reject) => {
|
||||
listener.once(signal, resolve)
|
||||
listener.once('error', reject)
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { expect, test } from 'vitest'
|
||||
import _ from 'lodash'
|
||||
import * as config from './config'
|
||||
import * as chat from '../src'
|
||||
|
||||
const invalidApiUrl = config.get('API_URL') + '1234'
|
||||
|
||||
test('client throws if webhook id is incorrect', async () => {
|
||||
const client = new chat.Client({ apiUrl: invalidApiUrl })
|
||||
const userPromise = client.createUser({ id: 'invalid' })
|
||||
expect(userPromise).rejects.toThrow(chat.ChatClientError)
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
import { api, signals } from '@botpress/chat-api'
|
||||
import pathlib from 'path'
|
||||
|
||||
const main = async (argv: string[]) => {
|
||||
const outDir = argv[0]
|
||||
if (!outDir) {
|
||||
throw new Error('Missing output directory')
|
||||
}
|
||||
const clientDir = pathlib.join(outDir, 'client')
|
||||
const signalsDir = pathlib.join(outDir, 'signals')
|
||||
await api.exportClient(clientDir, { generator: 'opapi' })
|
||||
await signals.exportSchemas(signalsDir)
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2))
|
||||
.then(() => {
|
||||
process.exit(0)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@botpress/chat",
|
||||
"version": "1.0.0",
|
||||
"description": "Botpress Chat API Client",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"url": "https://github.com/botpress/botpress"
|
||||
},
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"check:type": "tsc --noEmit",
|
||||
"generate": "ts-node -T ./openapi.ts ./src/gen",
|
||||
"build:type": "tsc -p ./tsconfig.build.json",
|
||||
"build:browser": "ts-node -T ./build.ts --browser",
|
||||
"build:node": "ts-node -T ./build.ts --node",
|
||||
"build": "pnpm build:type && pnpm build:node && pnpm build:browser",
|
||||
"test:e2e": "vitest run --config vitest.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "1.2.5",
|
||||
"browser-or-node": "^2.1.1",
|
||||
"event-source-polyfill": "^1.0.31",
|
||||
"eventsource": "^2.0.2",
|
||||
"jose": "^6.1.3",
|
||||
"qs": "^6.11.0",
|
||||
"verror": "^1.10.1",
|
||||
"zod": "^3.21.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/chat-api": "workspace:*",
|
||||
"@types/event-source-polyfill": "^1.0.2",
|
||||
"@types/eventsource": "^1.1.12",
|
||||
"@types/json-schema": "^7.0.12",
|
||||
"@types/lodash": "^4.14.191",
|
||||
"@types/qs": "^6.9.7",
|
||||
"@types/uuid": "^9.0.1",
|
||||
"@types/verror": "^1.10.6",
|
||||
"@types/web": "^0.0.115",
|
||||
"@types/ws": "^8.5.10",
|
||||
"dotenv": "^16.4.4",
|
||||
"esbuild": "^0.25.10",
|
||||
"esbuild-plugin-polyfill-node": "^0.3.0",
|
||||
"lodash": "^4.17.21",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23.0.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.3"
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
# Chat Client
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @botpress/chat # for npm
|
||||
yarn add @botpress/chat # for yarn
|
||||
pnpm add @botpress/chat # for pnpm
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### API Queries
|
||||
|
||||
The `@botpress/chat` package exports a `Client` class that can be used to interact with the Botpress Chat API.
|
||||
|
||||
```ts
|
||||
import _ from 'lodash'
|
||||
import * as chat from '@botpress/chat'
|
||||
|
||||
const main = async () => {
|
||||
/**
|
||||
* You can find your webhook id in the the Botpress Dashboard.
|
||||
* Navigate to your bot's Chat Integration configuration. Look for:
|
||||
* https://webhook.botpress.cloud/$YOUR_WEBHOOK_ID
|
||||
*/
|
||||
const webhookId = process.env.WEBHOOK_ID
|
||||
if (!webhookId) {
|
||||
throw new Error('WEBHOOK_ID is required')
|
||||
}
|
||||
|
||||
// 0. connect and create a user
|
||||
const client = await chat.Client.connect({ webhookId })
|
||||
|
||||
// 1. create a conversation
|
||||
const { conversation } = await client.createConversation({})
|
||||
|
||||
// 2. send a message
|
||||
await client.createMessage({
|
||||
conversationId: conversation.id,
|
||||
payload: {
|
||||
type: 'text',
|
||||
text: 'hello world',
|
||||
},
|
||||
})
|
||||
|
||||
// 3. sleep for a bit
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
|
||||
// 4. list messages
|
||||
const { messages } = await client
|
||||
.listMessages({
|
||||
conversationId: conversation.id,
|
||||
})
|
||||
.then(({ messages }) => ({
|
||||
messages: _.sortBy(messages, (m) => new Date(m.createdAt).getTime()),
|
||||
}))
|
||||
|
||||
const botResponse = messages[1]
|
||||
console.log("Bot's response:", botResponse.payload)
|
||||
}
|
||||
|
||||
void main()
|
||||
.then(() => {
|
||||
console.log('done')
|
||||
process.exit(0)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
```
|
||||
|
||||
### Realtime Events
|
||||
|
||||
You can also listen for messages and events in real-time.
|
||||
|
||||
```ts
|
||||
// ...
|
||||
|
||||
const listener = await client.listenConversation({
|
||||
id: conversation.id,
|
||||
})
|
||||
|
||||
const botResponse = await new Promise<chat.Message>((resolve) => {
|
||||
const onMessage = (ev: chat.Signals['message_created']) => {
|
||||
if (ev.userId === client.user.id) {
|
||||
// message created by my current user, ignoring...
|
||||
return
|
||||
}
|
||||
listener.off('message_created', onMessage)
|
||||
resolve(ev)
|
||||
}
|
||||
listener.on('message_created', onMessage)
|
||||
})
|
||||
|
||||
console.log("Bot's response:", botResponse.payload)
|
||||
```
|
||||
|
||||
### Reconnection
|
||||
|
||||
The `Client` class does not automatically reconnect to the server if the connection is lost. This allow's you to handle reconnection in a way that makes sense for your application. To be notified when the connection is lost, you can listen for the `error` event:
|
||||
|
||||
```ts
|
||||
const state = { messages } // your application state
|
||||
|
||||
const onDisconnection = async () => {
|
||||
try {
|
||||
await listener.connect()
|
||||
const { messages } = await client.listMessages({ conversationId: conversation.id })
|
||||
state.messages = messages
|
||||
} catch (thrown) {
|
||||
console.error('failed to reconnect, retrying...', thrown)
|
||||
setTimeout(onDisconnection, 1000) // consider using a backoff strategy
|
||||
}
|
||||
}
|
||||
|
||||
listener.on('error', (err) => {
|
||||
console.error('connection lost', err)
|
||||
void onDisconnection()
|
||||
})
|
||||
```
|
||||
@@ -0,0 +1,293 @@
|
||||
import axios from 'axios'
|
||||
import { isBrowser } from 'browser-or-node'
|
||||
import * as consts from './consts'
|
||||
import * as errors from './errors'
|
||||
import { ServerEventsProtocol } from './eventsource'
|
||||
import { apiVersion, Client as AutoGeneratedClient } from './gen/client'
|
||||
import { signJwt } from './jwt'
|
||||
import { AsyncCollection } from './listing'
|
||||
import { SignalListener } from './signal-listener'
|
||||
import * as types from './types'
|
||||
|
||||
const _100mb = 100 * 1024 * 1024
|
||||
const maxBodyLength = _100mb
|
||||
const maxContentLength = _100mb
|
||||
const defaultTimeout = 60_000
|
||||
|
||||
const _createAuthClient = Symbol('_createAuthClient')
|
||||
|
||||
type Merge<A, B> = Omit<A, keyof B> & B
|
||||
type IClient = Merge<
|
||||
{
|
||||
[K in types.ClientOperation]: (x: types.ClientRequests[K]) => Promise<types.ClientResponses[K]>
|
||||
},
|
||||
{
|
||||
listenConversation: (
|
||||
args: types.ClientRequests['listenConversation'] & { protocol?: ServerEventsProtocol }
|
||||
) => Promise<SignalListener>
|
||||
}
|
||||
>
|
||||
|
||||
type IAuthenticatedClient = Merge<
|
||||
{
|
||||
[K in types.AuthenticatedOperation]: (x: types.AuthenticatedClientRequests[K]) => Promise<types.ClientResponses[K]>
|
||||
},
|
||||
{
|
||||
listenConversation: (
|
||||
args: types.AuthenticatedClientRequests['listenConversation'] & { protocol?: ServerEventsProtocol }
|
||||
) => Promise<SignalListener>
|
||||
}
|
||||
>
|
||||
|
||||
export class Client implements IClient {
|
||||
private _connectionTested = false
|
||||
private _auto: AutoGeneratedClient
|
||||
|
||||
public constructor(public readonly props: Readonly<types.ClientProps>) {
|
||||
const axiosClient = Client._createAxios(props)
|
||||
this._auto = new AutoGeneratedClient(axiosClient)
|
||||
}
|
||||
|
||||
public get apiVersion() {
|
||||
return apiVersion
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or creates a user based on the provided props and returns an authenticated client.
|
||||
*/
|
||||
public static async connect(props: types.ConnectProps): Promise<AuthenticatedClient> {
|
||||
const { userId, userKey, encryptionKey, ...clientProps } = props
|
||||
const client = new Client(clientProps)
|
||||
await client._testConnection()
|
||||
|
||||
if (userKey) {
|
||||
const { user } = await client.getOrCreateUser({ 'x-user-key': userKey })
|
||||
return AuthenticatedClient[_createAuthClient](client, { ...user, key: userKey })
|
||||
}
|
||||
|
||||
if (encryptionKey) {
|
||||
if (!userId) {
|
||||
throw new errors.ChatConfigError(
|
||||
'userId is required when connecting with an encryption key. You may pick any userId of your choice that is not already taken by another user.'
|
||||
)
|
||||
}
|
||||
|
||||
const userKey = await signJwt({ id: userId }, encryptionKey)
|
||||
const { user } = await client.getOrCreateUser({ 'x-user-key': userKey })
|
||||
return AuthenticatedClient[_createAuthClient](client, { ...user, key: userKey })
|
||||
}
|
||||
|
||||
const { user, key } = await client.createUser({ id: userId })
|
||||
return AuthenticatedClient[_createAuthClient](client, { ...user, key })
|
||||
}
|
||||
|
||||
public readonly createConversation: IClient['createConversation'] = (x) => this._call('createConversation', x)
|
||||
public readonly getConversation: IClient['getConversation'] = (x) => this._call('getConversation', x)
|
||||
public readonly getOrCreateConversation: IClient['getOrCreateConversation'] = (x) =>
|
||||
this._call('getOrCreateConversation', x)
|
||||
public readonly deleteConversation: IClient['deleteConversation'] = (x) => this._call('deleteConversation', x)
|
||||
public readonly listConversations: IClient['listConversations'] = (x) => this._call('listConversations', x)
|
||||
public readonly listMessages: IClient['listMessages'] = (x) => this._call('listMessages', x)
|
||||
public readonly addParticipant: IClient['addParticipant'] = (x) => this._call('addParticipant', x)
|
||||
public readonly removeParticipant: IClient['removeParticipant'] = (x) => this._call('removeParticipant', x)
|
||||
public readonly getParticipant: IClient['getParticipant'] = (x) => this._call('getParticipant', x)
|
||||
public readonly listParticipants: IClient['listParticipants'] = (x) => this._call('listParticipants', x)
|
||||
public readonly createMessage: IClient['createMessage'] = (x) => this._call('createMessage', x)
|
||||
public readonly getMessage: IClient['getMessage'] = (x) => this._call('getMessage', x)
|
||||
public readonly deleteMessage: IClient['deleteMessage'] = (x) => this._call('deleteMessage', x)
|
||||
public readonly createUser: IClient['createUser'] = (x) => this._call('createUser', x)
|
||||
public readonly getUser: IClient['getUser'] = (x) => this._call('getUser', x)
|
||||
public readonly getOrCreateUser: IClient['getOrCreateUser'] = (x) => this._call('getOrCreateUser', x)
|
||||
public readonly updateUser: IClient['updateUser'] = (x) => this._call('updateUser', x)
|
||||
public readonly deleteUser: IClient['deleteUser'] = (x) => this._call('deleteUser', x)
|
||||
public readonly createEvent: IClient['createEvent'] = (x) => this._call('createEvent', x)
|
||||
public readonly getEvent: IClient['getEvent'] = (x) => this._call('getEvent', x)
|
||||
|
||||
public get list() {
|
||||
return {
|
||||
conversations: (props: types.ClientRequests['listConversations']) =>
|
||||
new AsyncCollection(({ nextToken }) =>
|
||||
this.listConversations({ nextToken, ...props }).then((r) => ({ ...r, items: r.conversations }))
|
||||
),
|
||||
messages: (props: types.ClientRequests['listMessages']) =>
|
||||
new AsyncCollection(({ nextToken }) =>
|
||||
this.listMessages({ nextToken, ...props }).then((r) => ({ ...r, items: r.messages }))
|
||||
),
|
||||
participants: (props: types.ClientRequests['listParticipants']) =>
|
||||
new AsyncCollection(({ nextToken }) =>
|
||||
this.listParticipants({ nextToken, ...props }).then((r) => ({ ...r, items: r.participants }))
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
public readonly listenConversation: IClient['listenConversation'] = async ({
|
||||
id,
|
||||
'x-user-key': userKey,
|
||||
protocol,
|
||||
}) => {
|
||||
const signalListener = await SignalListener.listen({
|
||||
url: this._apiUrl,
|
||||
conversationId: id,
|
||||
userKey,
|
||||
debug: this.props.debug ?? false,
|
||||
protocol: protocol ?? 'sse',
|
||||
})
|
||||
return signalListener
|
||||
}
|
||||
|
||||
private _call = async (operation: types.ClientOperation, args: any): Promise<any> => {
|
||||
try {
|
||||
await this._testConnection()
|
||||
const response = await this._auto[operation](args)
|
||||
const res = this._checkPayloadForError(response)
|
||||
return res
|
||||
} catch (thrown) {
|
||||
if (errors.isApiError(thrown)) {
|
||||
throw thrown
|
||||
}
|
||||
throw errors.ChatClientError.map(thrown)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Chat-API is called like any other integrations by sending requests to the bridge webhook endpoint.
|
||||
* This endpoint may return a successful status code even when the payload contains an error.
|
||||
* This method parses the payload to check for an error and throws an error if one is found.
|
||||
*/
|
||||
private _checkPayloadForError = <T>(response: unknown): T => {
|
||||
if (typeof response !== 'object' || response === null) {
|
||||
return response as T
|
||||
}
|
||||
|
||||
if (!('code' in response)) {
|
||||
return response as T
|
||||
}
|
||||
|
||||
const { code } = response
|
||||
if (typeof code !== 'number') {
|
||||
return response as T
|
||||
}
|
||||
|
||||
if (code < 400 || code >= 600) {
|
||||
return response as T
|
||||
}
|
||||
|
||||
const message = 'message' in response ? String(response['message']) : 'An error occurred'
|
||||
throw new errors.ChatHTTPError(code, message)
|
||||
}
|
||||
|
||||
private static _createAxios = (props: types.ClientProps) => {
|
||||
const headers: types.Headers = {
|
||||
...props.headers,
|
||||
}
|
||||
const timeout = props.timeout ?? defaultTimeout
|
||||
const withCredentials = isBrowser
|
||||
const baseURL = this._getApiUrl(props)
|
||||
return axios.create({
|
||||
baseURL,
|
||||
headers,
|
||||
withCredentials,
|
||||
timeout,
|
||||
maxBodyLength,
|
||||
maxContentLength,
|
||||
validateStatus: (status) => status >= 200 && status < 400,
|
||||
})
|
||||
}
|
||||
|
||||
private get _apiUrl() {
|
||||
return Client._getApiUrl(this.props)
|
||||
}
|
||||
|
||||
private static _getApiUrl = (props: types.ClientProps) => {
|
||||
if ('apiUrl' in props) {
|
||||
return props.apiUrl
|
||||
}
|
||||
|
||||
const baseApiUrl = props.baseApiUrl ?? consts.defaultBaseApiUrl
|
||||
const { webhookId } = props
|
||||
return `${baseApiUrl}/${webhookId}`
|
||||
}
|
||||
|
||||
private _testConnection = async () => {
|
||||
if (this._connectionTested) {
|
||||
return
|
||||
}
|
||||
|
||||
const url = `${this._apiUrl}/hello`
|
||||
const axiosInstance = axios.create({ baseURL: url })
|
||||
try {
|
||||
const response = await axiosInstance.get('/')
|
||||
this._checkPayloadForError(response.data)
|
||||
} catch (thrown) {
|
||||
throw errors.ChatClientError.wrap(thrown, `Failed to connect to url "${this._apiUrl}"`)
|
||||
}
|
||||
|
||||
this._connectionTested = true
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthenticatedClient implements IAuthenticatedClient {
|
||||
private constructor(
|
||||
private _client: Client,
|
||||
public readonly user: types.AuthenticatedUser
|
||||
) {}
|
||||
|
||||
// can not be instantiated outside of this module
|
||||
public static [_createAuthClient] = (client: Client, user: types.AuthenticatedUser) => {
|
||||
return new AuthenticatedClient(client, user)
|
||||
}
|
||||
|
||||
public get apiVersion() {
|
||||
return this._client.apiVersion
|
||||
}
|
||||
|
||||
public readonly createConversation: IAuthenticatedClient['createConversation'] = (x) =>
|
||||
this._client.createConversation({ 'x-user-key': this.user.key, ...x })
|
||||
public readonly getConversation: IAuthenticatedClient['getConversation'] = (x) =>
|
||||
this._client.getConversation({ 'x-user-key': this.user.key, ...x })
|
||||
public readonly getOrCreateConversation: IAuthenticatedClient['getOrCreateConversation'] = (x) =>
|
||||
this._client.getOrCreateConversation({ 'x-user-key': this.user.key, ...x })
|
||||
public readonly deleteConversation: IAuthenticatedClient['deleteConversation'] = (x) =>
|
||||
this._client.deleteConversation({ 'x-user-key': this.user.key, ...x })
|
||||
public readonly listConversations: IAuthenticatedClient['listConversations'] = (x) =>
|
||||
this._client.listConversations({ 'x-user-key': this.user.key, ...x })
|
||||
public readonly listMessages: IAuthenticatedClient['listMessages'] = (x) =>
|
||||
this._client.listMessages({ 'x-user-key': this.user.key, ...x })
|
||||
public readonly listenConversation: IAuthenticatedClient['listenConversation'] = (x) =>
|
||||
this._client.listenConversation({ 'x-user-key': this.user.key, ...x })
|
||||
public readonly addParticipant: IAuthenticatedClient['addParticipant'] = (x) =>
|
||||
this._client.addParticipant({ 'x-user-key': this.user.key, ...x })
|
||||
public readonly removeParticipant: IAuthenticatedClient['removeParticipant'] = (x) =>
|
||||
this._client.removeParticipant({ 'x-user-key': this.user.key, ...x })
|
||||
public readonly getParticipant: IAuthenticatedClient['getParticipant'] = (x) =>
|
||||
this._client.getParticipant({ 'x-user-key': this.user.key, ...x })
|
||||
public readonly listParticipants: IAuthenticatedClient['listParticipants'] = (x) =>
|
||||
this._client.listParticipants({ 'x-user-key': this.user.key, ...x })
|
||||
public readonly createMessage: IAuthenticatedClient['createMessage'] = (x) =>
|
||||
this._client.createMessage({ 'x-user-key': this.user.key, ...x })
|
||||
public readonly getMessage: IAuthenticatedClient['getMessage'] = (x) =>
|
||||
this._client.getMessage({ 'x-user-key': this.user.key, ...x })
|
||||
public readonly deleteMessage: IAuthenticatedClient['deleteMessage'] = (x) =>
|
||||
this._client.deleteMessage({ 'x-user-key': this.user.key, ...x })
|
||||
public readonly getUser: IAuthenticatedClient['getUser'] = (x) =>
|
||||
this._client.getUser({ 'x-user-key': this.user.key, ...x })
|
||||
public readonly updateUser: IAuthenticatedClient['updateUser'] = (x) =>
|
||||
this._client.updateUser({ 'x-user-key': this.user.key, ...x })
|
||||
public readonly deleteUser: IAuthenticatedClient['deleteUser'] = (x) =>
|
||||
this._client.deleteUser({ 'x-user-key': this.user.key, ...x })
|
||||
public readonly createEvent: IAuthenticatedClient['createEvent'] = (x) =>
|
||||
this._client.createEvent({ 'x-user-key': this.user.key, ...x })
|
||||
public readonly getEvent: IAuthenticatedClient['getEvent'] = (x) =>
|
||||
this._client.getEvent({ 'x-user-key': this.user.key, ...x })
|
||||
|
||||
public get list() {
|
||||
return {
|
||||
conversations: (x: types.AuthenticatedClientRequests['listConversations']) =>
|
||||
this._client.list.conversations({ 'x-user-key': this.user.key, ...x }),
|
||||
messages: (x: types.AuthenticatedClientRequests['listMessages']) =>
|
||||
this._client.list.messages({ 'x-user-key': this.user.key, ...x }),
|
||||
participants: (x: types.AuthenticatedClientRequests['listParticipants']) =>
|
||||
this._client.list.participants({ 'x-user-key': this.user.key, ...x }),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const defaultBaseApiUrl = 'https://chat.botpress.cloud'
|
||||
@@ -0,0 +1,69 @@
|
||||
import axios, { AxiosError } from 'axios'
|
||||
import { VError } from 'verror'
|
||||
|
||||
export * from './gen/client/errors'
|
||||
|
||||
export class ChatClientError extends VError {
|
||||
public static wrap(thrown: unknown, message: string): ChatClientError {
|
||||
const err = ChatClientError.map(thrown)
|
||||
return new ChatClientError(err, message ?? '')
|
||||
}
|
||||
|
||||
public static map(thrown: unknown): ChatClientError {
|
||||
if (thrown instanceof ChatClientError) {
|
||||
return thrown
|
||||
}
|
||||
if (axios.isAxiosError(thrown)) {
|
||||
return ChatHTTPError.fromAxios(thrown)
|
||||
}
|
||||
if (thrown instanceof Error) {
|
||||
const { message } = thrown
|
||||
return new ChatClientError(message)
|
||||
}
|
||||
return new ChatClientError(String(thrown))
|
||||
}
|
||||
|
||||
public constructor(error: ChatClientError, message: string)
|
||||
public constructor(message: string)
|
||||
public constructor(first: ChatClientError | string, second?: string) {
|
||||
if (typeof first === 'string') {
|
||||
super(first)
|
||||
return
|
||||
}
|
||||
super(first, second!)
|
||||
}
|
||||
}
|
||||
|
||||
export class ChatHTTPError extends ChatClientError {
|
||||
public constructor(
|
||||
public readonly status: number | undefined,
|
||||
message: string
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
|
||||
public static fromAxios(e: AxiosError<{ message?: string }>): ChatHTTPError {
|
||||
const message = this._axiosMsg(e)
|
||||
return new ChatHTTPError(e.response?.status, message)
|
||||
}
|
||||
|
||||
private static _axiosMsg(e: AxiosError<{ message?: string }>): string {
|
||||
let message = e.message
|
||||
if (e.response?.statusText) {
|
||||
message += `\n ${e.response?.statusText}`
|
||||
}
|
||||
if (e.response?.status && e.request?.method && e.request?.path) {
|
||||
message += `\n (${e.response?.status}) ${e.request.method} ${e.request.path}`
|
||||
}
|
||||
if (e.response?.data?.message) {
|
||||
message += `\n ${e.response?.data?.message}`
|
||||
}
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
export class ChatConfigError extends ChatClientError {
|
||||
public constructor(message: string) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
export type ListenStatus = 'keep-listening' | 'stop-listening'
|
||||
|
||||
export class EventEmitter<E extends object> {
|
||||
private _listeners: {
|
||||
[K in keyof E]?: ((event: E[K]) => void)[]
|
||||
} = {}
|
||||
|
||||
public emit<K extends keyof E>(type: K, event: E[K]) {
|
||||
const listeners = this._listeners[type]
|
||||
if (!listeners) {
|
||||
return
|
||||
}
|
||||
for (const listener of [...listeners]) {
|
||||
listener(event)
|
||||
}
|
||||
}
|
||||
|
||||
public onceOrMore<K extends keyof E>(type: K, listener: (event: E[K]) => ListenStatus) {
|
||||
const wrapped = (event: E[K]) => {
|
||||
const status = listener(event)
|
||||
if (status === 'stop-listening') {
|
||||
this.off(type, wrapped)
|
||||
}
|
||||
}
|
||||
this.on(type, wrapped)
|
||||
}
|
||||
|
||||
public once<K extends keyof E>(type: K, listener: (event: E[K]) => void) {
|
||||
const wrapped = (event: E[K]) => {
|
||||
this.off(type, wrapped)
|
||||
listener(event)
|
||||
}
|
||||
this.on(type, wrapped)
|
||||
}
|
||||
|
||||
public on<K extends keyof E>(type: K, listener: (event: E[K]) => void) {
|
||||
if (!this._listeners[type]) {
|
||||
this._listeners[type] = []
|
||||
}
|
||||
this._listeners[type]!.push(listener)
|
||||
}
|
||||
|
||||
public off<K extends keyof E>(type: K, listener: (event: E[K]) => void) {
|
||||
const listeners = this._listeners[type]
|
||||
if (!listeners) {
|
||||
return
|
||||
}
|
||||
const index = listeners.indexOf(listener)
|
||||
if (index !== -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
public cleanup() {
|
||||
this._listeners = {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { isBrowser } from 'browser-or-node'
|
||||
import type EventSourceBrowser from 'event-source-polyfill'
|
||||
import type EventSourceNodeJs from 'eventsource'
|
||||
import { EventEmitter } from './event-emitter'
|
||||
|
||||
type WebSocketOnOpen = NonNullable<WebSocket['onopen']>
|
||||
type WebSocketOnMessage = NonNullable<WebSocket['onmessage']>
|
||||
type WebSocketOnError = NonNullable<WebSocket['onerror']>
|
||||
type WebSocketOnClose = NonNullable<WebSocket['onclose']>
|
||||
|
||||
type WebSocketOpenEvent = Parameters<WebSocketOnOpen>[0]
|
||||
type WebSocketMessageEvent = Parameters<WebSocketOnMessage>[0]
|
||||
type WebSocketErrorEvent = Parameters<WebSocketOnError>[0]
|
||||
type WebSocketCloseEvent = Parameters<WebSocketOnClose>[0]
|
||||
|
||||
type NodeOnOpen = EventSourceNodeJs['onopen']
|
||||
type NodeOnMessage = EventSourceNodeJs['onmessage']
|
||||
type NodeOnError = EventSourceNodeJs['onerror']
|
||||
|
||||
type NodeOpenEvent = Parameters<NodeOnOpen>[0]
|
||||
type NodeMessageEvent = Parameters<NodeOnMessage>[0]
|
||||
type NodeErrorEvent = Parameters<NodeOnError>[0]
|
||||
|
||||
type BrowserOnOpen = NonNullable<EventSourceBrowser.EventSourcePolyfill['onopen']>
|
||||
type BrowserOnMessage = NonNullable<EventSourceBrowser.EventSourcePolyfill['onmessage']>
|
||||
type BrowserOnError = NonNullable<EventSourceBrowser.EventSourcePolyfill['onerror']>
|
||||
|
||||
type BrowserOpenEvent = Parameters<BrowserOnOpen>[0]
|
||||
type BrowserMessageEvent = Parameters<BrowserOnMessage>[0]
|
||||
type BrowserErrorEvent = Parameters<BrowserOnError>[0]
|
||||
|
||||
export type OpenEvent = NodeOpenEvent | BrowserOpenEvent | WebSocketOpenEvent
|
||||
export type MessageEvent = NodeMessageEvent | BrowserMessageEvent | WebSocketMessageEvent
|
||||
export type ErrorEvent = NodeErrorEvent | BrowserErrorEvent | WebSocketErrorEvent
|
||||
export type CloseEvent = WebSocketCloseEvent
|
||||
|
||||
export type Events = {
|
||||
open: OpenEvent
|
||||
message: MessageEvent
|
||||
error: ErrorEvent
|
||||
close: CloseEvent
|
||||
}
|
||||
|
||||
export type ServerEventsProtocol = 'websocket' | 'sse'
|
||||
|
||||
export type Props = {
|
||||
headers?: Record<string, string>
|
||||
protocol?: ServerEventsProtocol
|
||||
}
|
||||
|
||||
type ServerEventsSource = EventSourceBrowser.EventSourcePolyfill | EventSourceNodeJs | WebSocket
|
||||
|
||||
const makeEventSource = (url: string, props: Props = {}) => {
|
||||
let source: ServerEventsSource
|
||||
const emitter = new EventEmitter<Events>()
|
||||
if (props.protocol === 'websocket') {
|
||||
url = url.replace(/^http/, 'ws')
|
||||
if (props.headers?.['x-user-key']) {
|
||||
url = `${url}?x-user-key=${encodeURIComponent(props.headers['x-user-key'])}`
|
||||
}
|
||||
source = new WebSocket(url)
|
||||
source.onclose = (ev: CloseEvent) => emitter.emit('close', ev)
|
||||
} else {
|
||||
if (isBrowser) {
|
||||
const module: typeof EventSourceBrowser = require('event-source-polyfill')
|
||||
source = new module.EventSourcePolyfill(url, { headers: props.headers })
|
||||
} else {
|
||||
const module: typeof EventSourceNodeJs = require('eventsource')
|
||||
source = new module(url, { headers: props.headers })
|
||||
}
|
||||
}
|
||||
source.onopen = (ev: OpenEvent) => emitter.emit('open', ev)
|
||||
source.onmessage = (ev: MessageEvent) => emitter.emit('message', ev)
|
||||
source.onerror = (ev: ErrorEvent) => emitter.emit('error', ev)
|
||||
return {
|
||||
emitter,
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
export type EventSourceEmitter = {
|
||||
on: EventEmitter<Events>['on']
|
||||
close: () => void
|
||||
}
|
||||
|
||||
export const listenEventSource = async (url: string, props: Props = {}): Promise<EventSourceEmitter> => {
|
||||
const { emitter, source } = makeEventSource(url, props)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
emitter.on('open', () => {
|
||||
resolve()
|
||||
})
|
||||
emitter.on('error', (thrown) => {
|
||||
reject(thrown)
|
||||
})
|
||||
emitter.on('close', () => {
|
||||
reject(new Error('Connection closed before opening'))
|
||||
})
|
||||
}).finally(() => emitter.cleanup())
|
||||
|
||||
return {
|
||||
on: emitter.on.bind(emitter),
|
||||
close: () => {
|
||||
emitter.cleanup()
|
||||
source.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * as axios from 'axios'
|
||||
export * from './types'
|
||||
export * from './errors'
|
||||
export * from './client'
|
||||
export * from './signal-listener'
|
||||
export { ServerEventsProtocol } from './eventsource'
|
||||
@@ -0,0 +1,16 @@
|
||||
import { type JWTPayload, SignJWT } from 'jose'
|
||||
import { ChatClientError } from './errors'
|
||||
|
||||
export async function signJwt(payload: JWTPayload, encryptionKey: string) {
|
||||
try {
|
||||
return await new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.sign(new TextEncoder().encode(encryptionKey))
|
||||
} catch (thrown: unknown) {
|
||||
throw ChatClientError.wrap(
|
||||
thrown,
|
||||
'Failed to sign the user key. Signing requires the WebCrypto API (Node.js 20+, or a secure context in browsers).'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export type PageLister<R> = (t: { nextToken?: string }) => Promise<{ items: R[]; meta: { nextToken?: string } }>
|
||||
export class AsyncCollection<T> {
|
||||
public constructor(private _list: PageLister<T>) {}
|
||||
|
||||
public async *[Symbol.asyncIterator]() {
|
||||
let nextToken: string | undefined
|
||||
do {
|
||||
const { items, meta } = await this._list({ nextToken })
|
||||
nextToken = meta.nextToken
|
||||
for (const item of items) {
|
||||
yield item
|
||||
}
|
||||
} while (nextToken)
|
||||
}
|
||||
|
||||
public async collect(props: { limit?: number } = {}) {
|
||||
const limit = props.limit ?? Number.POSITIVE_INFINITY
|
||||
const arr: T[] = []
|
||||
let count = 0
|
||||
for await (const item of this) {
|
||||
arr.push(item)
|
||||
count++
|
||||
if (count >= limit) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return arr
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { EventEmitter } from './event-emitter'
|
||||
import { listenEventSource, EventSourceEmitter, MessageEvent, ErrorEvent, ServerEventsProtocol } from './eventsource'
|
||||
import { zod as signals, Types } from './gen/signals'
|
||||
import { WatchDog } from './watchdog'
|
||||
|
||||
const CONNECTION_TIMEOUT = 60_000
|
||||
const DEFAULT_ERROR_MESSAGE = 'unknown error'
|
||||
|
||||
type ValueOf<T> = T[keyof T]
|
||||
|
||||
type _Signals = Types & {
|
||||
unknown: {
|
||||
type: 'unknown'
|
||||
data: unknown
|
||||
}
|
||||
}
|
||||
|
||||
type SignalListenerState =
|
||||
| {
|
||||
status: 'disconnected'
|
||||
}
|
||||
| {
|
||||
status: 'connecting'
|
||||
connectionPromise: Promise<EventSourceEmitter>
|
||||
}
|
||||
| {
|
||||
status: 'connected'
|
||||
source: EventSourceEmitter
|
||||
watchdog: WatchDog
|
||||
}
|
||||
|
||||
export type Signals = {
|
||||
[K in keyof _Signals as _Signals[K]['type']]: _Signals[K]['data']
|
||||
}
|
||||
|
||||
type Events = Signals & {
|
||||
error: Error
|
||||
close: undefined
|
||||
}
|
||||
|
||||
export type SignalListenerStatus = SignalListenerState['status']
|
||||
|
||||
export type SignalListenerProps = {
|
||||
url: string
|
||||
userKey: string
|
||||
conversationId: string
|
||||
debug: boolean
|
||||
protocol?: ServerEventsProtocol
|
||||
}
|
||||
|
||||
export class SignalListener extends EventEmitter<Events> {
|
||||
private _state: SignalListenerState = { status: 'disconnected' }
|
||||
|
||||
private constructor(private _props: SignalListenerProps) {
|
||||
super()
|
||||
}
|
||||
|
||||
public static listen = async (props: SignalListenerProps): Promise<SignalListener> => {
|
||||
const inst = new SignalListener(props)
|
||||
await inst.connect()
|
||||
return inst
|
||||
}
|
||||
|
||||
public get status(): SignalListenerStatus {
|
||||
return this._state.status
|
||||
}
|
||||
|
||||
public readonly connect = async (): Promise<void> => {
|
||||
if (this._state.status === 'connected') {
|
||||
return
|
||||
}
|
||||
|
||||
if (this._state.status === 'connecting') {
|
||||
await this._state.connectionPromise
|
||||
return
|
||||
}
|
||||
|
||||
const connectionPromise = this._connect()
|
||||
|
||||
this._state = { status: 'connecting', connectionPromise }
|
||||
|
||||
await connectionPromise
|
||||
}
|
||||
|
||||
public readonly disconnect = async (): Promise<void> => {
|
||||
if (this._state.status === 'disconnected') {
|
||||
return
|
||||
}
|
||||
|
||||
let source: EventSourceEmitter
|
||||
let watchdog: WatchDog | undefined
|
||||
if (this._state.status === 'connecting') {
|
||||
source = await this._state.connectionPromise
|
||||
} else {
|
||||
source = this._state.source
|
||||
watchdog = this._state.watchdog
|
||||
}
|
||||
|
||||
this._disconnectSync(source, watchdog)
|
||||
}
|
||||
|
||||
private _connect = async (): Promise<EventSourceEmitter> => {
|
||||
const source = await listenEventSource(`${this._props.url}/conversations/${this._props.conversationId}/listen`, {
|
||||
headers: { 'x-user-key': this._props.userKey },
|
||||
protocol: this._props.protocol,
|
||||
})
|
||||
|
||||
const watchdog = WatchDog.init(CONNECTION_TIMEOUT)
|
||||
|
||||
source.on('message', this._handleMessage(source, watchdog))
|
||||
source.on('error', this._handleError(source, watchdog))
|
||||
source.on('close', this._handleClose(source, watchdog))
|
||||
watchdog.on('error', this._handleError(source, watchdog))
|
||||
|
||||
this._state = { status: 'connected', source, watchdog }
|
||||
return source
|
||||
}
|
||||
|
||||
private _disconnectSync = (source: EventSourceEmitter, watchdog?: WatchDog): void => {
|
||||
source.close()
|
||||
watchdog?.close()
|
||||
this._state = { status: 'disconnected' }
|
||||
}
|
||||
|
||||
private _handleMessage = (_source: EventSourceEmitter, watchdog: WatchDog) => (ev: MessageEvent) => {
|
||||
watchdog.reset()
|
||||
const signal = this._parseSignal(ev.data)
|
||||
this.emit(signal.type, signal.data)
|
||||
}
|
||||
|
||||
private _handleClose = (source: EventSourceEmitter, watchdog: WatchDog) => () => {
|
||||
this._disconnectSync(source, watchdog)
|
||||
this.emit('close', undefined)
|
||||
}
|
||||
|
||||
private _handleError = (source: EventSourceEmitter, watchdog: WatchDog) => (ev: ErrorEvent | Error) => {
|
||||
this._disconnectSync(source, watchdog)
|
||||
const err = this._toError(ev)
|
||||
this.emit('error', err)
|
||||
}
|
||||
|
||||
private _parseSignal = (data: unknown): ValueOf<_Signals> => {
|
||||
for (const [schemaName, schema] of Object.entries(signals)) {
|
||||
this._debug('trying to parse', schemaName)
|
||||
const parsedData = this._safeJsonParse(data)
|
||||
const parseResult = schema.safeParse(parsedData)
|
||||
if (parseResult.success) {
|
||||
this._debug('parsing successfull', schemaName, parseResult.data)
|
||||
return parseResult.data
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'unknown',
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
private _safeJsonParse = (x: any) => {
|
||||
try {
|
||||
return JSON.parse(x)
|
||||
} catch {
|
||||
return x
|
||||
}
|
||||
}
|
||||
|
||||
private _toError = (thrown: unknown): Error => {
|
||||
if (thrown instanceof Error) {
|
||||
return thrown
|
||||
}
|
||||
if (typeof thrown === 'string') {
|
||||
return new Error(thrown)
|
||||
}
|
||||
if (thrown === null) {
|
||||
return new Error(DEFAULT_ERROR_MESSAGE)
|
||||
}
|
||||
if (typeof thrown === 'object' && 'message' in thrown) {
|
||||
return this._toError(thrown.message)
|
||||
}
|
||||
try {
|
||||
const json = JSON.stringify(thrown)
|
||||
return new Error(json)
|
||||
} catch {
|
||||
return new Error(DEFAULT_ERROR_MESSAGE)
|
||||
}
|
||||
}
|
||||
|
||||
private _debug = (...args: any[]) => {
|
||||
if (!this._props.debug) {
|
||||
return
|
||||
}
|
||||
console.info(...args)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Client as AutoGeneratedClient, User } from './gen/client'
|
||||
|
||||
export type { Message, Conversation, User, Event } from './gen/client'
|
||||
|
||||
type AsyncFunction = (...args: any[]) => Promise<any>
|
||||
|
||||
type Simplify<T> = T extends (...args: infer A) => infer R
|
||||
? (...args: Simplify<A>) => Simplify<R>
|
||||
: T extends Buffer
|
||||
? Buffer
|
||||
: T extends Promise<infer R>
|
||||
? Promise<Simplify<R>>
|
||||
: T extends object
|
||||
? T extends infer O
|
||||
? { [K in keyof O]: Simplify<O[K]> }
|
||||
: never
|
||||
: T
|
||||
|
||||
export type Headers = Record<string, string>
|
||||
|
||||
type CommonClientProps = {
|
||||
timeout?: number
|
||||
headers?: Headers
|
||||
debug?: boolean
|
||||
}
|
||||
|
||||
type ApiUrlClientProps = CommonClientProps & { apiUrl: string }
|
||||
type WebhookIdClientProps = CommonClientProps & { webhookId: string; baseApiUrl?: string }
|
||||
|
||||
export type ClientProps = ApiUrlClientProps | WebhookIdClientProps
|
||||
|
||||
export type ConnectProps = ClientProps & {
|
||||
encryptionKey?: string
|
||||
userKey?: string
|
||||
userId?: string
|
||||
}
|
||||
|
||||
export type ClientOperation = Simplify<
|
||||
keyof {
|
||||
[K in keyof AutoGeneratedClient as AutoGeneratedClient[K] extends AsyncFunction ? K : never]: null
|
||||
}
|
||||
>
|
||||
|
||||
export type ClientRequests = {
|
||||
[K in ClientOperation]: Parameters<AutoGeneratedClient[K]>[0]
|
||||
}
|
||||
|
||||
export type ClientResponses = {
|
||||
[K in ClientOperation]: Simplify<Awaited<ReturnType<AutoGeneratedClient[K]>>>
|
||||
}
|
||||
|
||||
export type AuthenticatedOperation = Exclude<ClientOperation, 'createUser' | 'getOrCreateUser'>
|
||||
export type AuthenticatedClientRequests = Simplify<{
|
||||
[K in AuthenticatedOperation]: Omit<ClientRequests[K], 'x-user-key'>
|
||||
}>
|
||||
|
||||
export type AuthenticatedUser = Simplify<
|
||||
User & {
|
||||
key: string
|
||||
}
|
||||
>
|
||||
@@ -0,0 +1,38 @@
|
||||
export class WatchDog {
|
||||
private _listeners: ((error: Error) => void)[] = []
|
||||
private _handle: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
private constructor(private _ms: number) {}
|
||||
|
||||
public static init = (ms: number): WatchDog => {
|
||||
const inst = new WatchDog(ms)
|
||||
inst.reset()
|
||||
return inst
|
||||
}
|
||||
|
||||
public reset() {
|
||||
if (this._handle) {
|
||||
clearTimeout(this._handle)
|
||||
}
|
||||
this._handle = setTimeout(() => {
|
||||
this._emitError(new Error('Client connection timed out'))
|
||||
}, this._ms)
|
||||
}
|
||||
|
||||
public on(_type: 'error', listener: (error: Error) => void) {
|
||||
this._listeners.push(listener)
|
||||
}
|
||||
|
||||
public close() {
|
||||
if (this._handle) {
|
||||
clearTimeout(this._handle)
|
||||
}
|
||||
this._listeners = []
|
||||
}
|
||||
|
||||
private _emitError(error: Error) {
|
||||
for (const listener of this._listeners) {
|
||||
listener(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"emitDeclarationOnly": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {},
|
||||
"include": ["src/**/*", "e2e/**/*", "package.json", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import 'dotenv/config'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
testTimeout: 20_000,
|
||||
include: ['./e2e/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
.ignore.me.*
|
||||
node_modules
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
/src
|
||||
/e2e
|
||||
/tsconfig.json
|
||||
|
||||
/templates/*/dist
|
||||
/templates/*/node_modules
|
||||
build.ts
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env node
|
||||
require("./dist/cli.js");
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
}
|
||||
+17
@@ -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 } }))
|
||||
+18
@@ -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"
|
||||
}
|
||||
}
|
||||
+19
@@ -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')
|
||||
},
|
||||
})
|
||||
+28
@@ -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"
|
||||
}
|
||||
}
|
||||
+32
@@ -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"]
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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, '')
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
require("./dist/init.js");
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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),
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
>
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user