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,24 @@
|
||||
{
|
||||
"name": "@botpress/common",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"scripts": {
|
||||
"check:type": "tsc --noEmit",
|
||||
"test": "vitest --run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@botpress/client": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"dedent": "^1.6.0",
|
||||
"marked": "^15.0.1",
|
||||
"openai": "^6.9.0",
|
||||
"posthog-node": "5.14.1",
|
||||
"preact": "^10.26.6",
|
||||
"preact-render-to-string": "^6.5.13",
|
||||
"remark": "^15.0.1",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mdast": "^4.0.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { collectableGenerator } from './collectable-generator'
|
||||
|
||||
describe.concurrent('collectableGenerator', () => {
|
||||
it('should make an async generator collectable', async () => {
|
||||
// Arrange
|
||||
async function* generateNumbers(): AsyncGenerator<number> {
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
yield i
|
||||
}
|
||||
}
|
||||
const enhancedGenerator = collectableGenerator(generateNumbers)
|
||||
const generator = enhancedGenerator()
|
||||
|
||||
// Act
|
||||
const result = await generator.collect()
|
||||
|
||||
// Assert
|
||||
expect(result).toStrictEqual([1, 2, 3, 4, 5])
|
||||
})
|
||||
|
||||
it.each([0, 3, 10, 12])('should respect collection limits (%i)', async (limit) => {
|
||||
// Arrange
|
||||
async function* generateNumbers(): AsyncGenerator<number> {
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
yield i
|
||||
}
|
||||
}
|
||||
const enhancedGenerator = collectableGenerator(generateNumbers)
|
||||
const generator = enhancedGenerator()
|
||||
|
||||
// Act
|
||||
const result = await generator.collect(limit)
|
||||
|
||||
// Assert
|
||||
expect(result).toStrictEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].slice(0, limit))
|
||||
})
|
||||
|
||||
it('should preserve original generator functionality', async () => {
|
||||
// Arrange
|
||||
async function* generateNumbers(): AsyncGenerator<number> {
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
yield i
|
||||
}
|
||||
return 'done'
|
||||
}
|
||||
const enhancedGenerator = collectableGenerator(generateNumbers)
|
||||
const generator = enhancedGenerator()
|
||||
|
||||
// Act & Assert (iterative generation)
|
||||
expect((await generator.next()).value).toBe(1)
|
||||
expect((await generator.next()).value).toBe(2)
|
||||
expect((await generator.next()).value).toBe(3)
|
||||
expect((await generator.next()).done).toBe(true)
|
||||
expect((await generator.next()).value).toBe(undefined)
|
||||
})
|
||||
|
||||
it('should pass arguments to the original generator', async () => {
|
||||
// Arrange
|
||||
async function* generateWithArgs(start: number, count: number): AsyncGenerator<number> {
|
||||
for (let i = 0; i < count; i++) {
|
||||
yield start + i
|
||||
}
|
||||
}
|
||||
const enhancedGenerator = collectableGenerator(generateWithArgs)
|
||||
|
||||
// Act
|
||||
const generator = enhancedGenerator(10, 3)
|
||||
const result = await generator.collect()
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual([10, 11, 12])
|
||||
})
|
||||
|
||||
it('should maintain context when calling the generator', async () => {
|
||||
// Arrange
|
||||
class NumberGenerator {
|
||||
constructor(private _offset: number) {}
|
||||
|
||||
async *generate(count: number): AsyncGenerator<number> {
|
||||
for (let i = 0; i < count; i++) {
|
||||
yield i + this._offset
|
||||
}
|
||||
}
|
||||
}
|
||||
const instance = new NumberGenerator(100)
|
||||
const enhancedGenerator = collectableGenerator(instance.generate.bind(instance))
|
||||
|
||||
// Act
|
||||
const generator = enhancedGenerator(3)
|
||||
const result = await generator.collect()
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual([100, 101, 102])
|
||||
})
|
||||
|
||||
it('should be able to proxy class generators', async () => {
|
||||
// Arrange
|
||||
class MyClass {
|
||||
private _field = 1 // to ensure we don't lose access to the class context
|
||||
|
||||
public generateNumbers = collectableGenerator(this._generateNumbers)
|
||||
|
||||
private async *_generateNumbers(): AsyncGenerator<number> {
|
||||
if (!this._field) {
|
||||
throw new Error('Unable to access class context')
|
||||
}
|
||||
|
||||
yield 1
|
||||
yield 2
|
||||
yield 3
|
||||
}
|
||||
}
|
||||
const instance = new MyClass()
|
||||
const generator = instance.generateNumbers()
|
||||
|
||||
// Act
|
||||
const result = await generator.collect()
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('should handle errors in the generator', async () => {
|
||||
// Arrange
|
||||
async function* errorGenerator(): AsyncGenerator<number> {
|
||||
yield 1
|
||||
throw new Error('Generator error')
|
||||
}
|
||||
const enhancedGenerator = collectableGenerator(errorGenerator)
|
||||
const generator = enhancedGenerator()
|
||||
|
||||
// Act & Assert
|
||||
await expect(generator.collect()).rejects.toThrow('Generator error')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
type CollectableAsyncGenerator<T, TReturn = unknown, TNext = unknown> = AsyncGenerator<T, TReturn, TNext> & {
|
||||
collect(limit?: number): Promise<T[]>
|
||||
}
|
||||
|
||||
export function collectableGenerator<T, TReturn = unknown, TNext = undefined, Args extends unknown[] = []>(
|
||||
generatorFn: (...args: Args) => AsyncGenerator<T, TReturn, TNext>
|
||||
): (...args: Args) => CollectableAsyncGenerator<T, TReturn, TNext> {
|
||||
return function (this: unknown, ...args: Args) {
|
||||
const originalGenerator = generatorFn.apply(this, args)
|
||||
|
||||
const enhancedGenerator = Object.assign(originalGenerator, {
|
||||
async collect(limit: number = Infinity): Promise<T[]> {
|
||||
const results: T[] = []
|
||||
|
||||
for await (const item of _takeFromGenerator(originalGenerator, limit)) {
|
||||
results.push(item as T)
|
||||
}
|
||||
|
||||
return results
|
||||
},
|
||||
}) as CollectableAsyncGenerator<T, TReturn, TNext>
|
||||
|
||||
return enhancedGenerator
|
||||
}
|
||||
}
|
||||
|
||||
async function* _takeFromGenerator<T, TReturn>(
|
||||
generator: AsyncGenerator<T, TReturn, unknown>,
|
||||
limit: number
|
||||
): AsyncGenerator<T, void, undefined> {
|
||||
let count = 0
|
||||
|
||||
for await (const item of generator) {
|
||||
if (count >= limit) break
|
||||
yield item
|
||||
count++
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { collectableGenerator } from './collectable-generator'
|
||||
@@ -0,0 +1,172 @@
|
||||
import { it, describe, expect } from 'vitest'
|
||||
import { buildConversationTranscript, type TranscriptFormatter } from './conversation-transcript'
|
||||
import type { Message, TextMessage } from './message-types'
|
||||
import type { MessageFormatter } from './message-formatter'
|
||||
|
||||
const MOCK_USER = {
|
||||
id: 'user-id',
|
||||
name: 'John Doe',
|
||||
tags: {},
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
}
|
||||
|
||||
const MOCK_BOT_USER = {
|
||||
id: 'bot-id',
|
||||
name: 'Botpress',
|
||||
tags: {},
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
}
|
||||
|
||||
const getMocks = () => ({
|
||||
client: {
|
||||
getUser: async ({ id }: { id: string }) => ({
|
||||
user: id === MOCK_USER.id ? MOCK_USER : MOCK_BOT_USER,
|
||||
}),
|
||||
},
|
||||
ctx: {
|
||||
botUserId: MOCK_BOT_USER.id,
|
||||
},
|
||||
})
|
||||
|
||||
describe.concurrent('buildConversationTranscript', () => {
|
||||
describe.concurrent('with default message formatters', () => {
|
||||
it('should format a conversation with text messages', async () => {
|
||||
// Arrange
|
||||
const { client, ctx } = getMocks()
|
||||
const messages: Message[] = [
|
||||
{
|
||||
source: { type: 'user', userId: MOCK_USER.id },
|
||||
type: 'text',
|
||||
payload: { text: 'Hello bot' },
|
||||
},
|
||||
{
|
||||
source: { type: 'bot' },
|
||||
type: 'text',
|
||||
payload: { text: 'Hello human' },
|
||||
},
|
||||
]
|
||||
|
||||
// Act
|
||||
const transcript = buildConversationTranscript({ messages, ctx, client })
|
||||
|
||||
// Assert
|
||||
await expect(transcript).resolves.toBe('👤 John Doe:\nHello bot\n\n---\n\n🤖 Botpress:\nHello human')
|
||||
})
|
||||
|
||||
it('should format a conversation with multiple message types', async () => {
|
||||
// Arrange
|
||||
const { client, ctx } = getMocks()
|
||||
const messages: Message[] = [
|
||||
{
|
||||
source: { type: 'user', userId: MOCK_USER.id },
|
||||
type: 'text',
|
||||
payload: { text: 'Check this out' },
|
||||
},
|
||||
{
|
||||
source: { type: 'user', userId: MOCK_USER.id },
|
||||
type: 'image',
|
||||
payload: { imageUrl: 'https://example.com/image.jpg' },
|
||||
},
|
||||
{
|
||||
source: { type: 'bot' },
|
||||
type: 'card',
|
||||
payload: {
|
||||
title: 'Cool Card',
|
||||
subtitle: 'A nice subtitle',
|
||||
imageUrl: 'https://example.com/card.jpg',
|
||||
actions: [{ action: 'url', label: 'Visit', value: 'https://example.com' }],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// Act
|
||||
const transcript = buildConversationTranscript({ messages, ctx, client })
|
||||
|
||||
// Assert
|
||||
await expect(transcript).resolves.toBe(
|
||||
'👤 John Doe:\nCheck this out\n\n---\n\n👤 John Doe:\n[ Image: https://example.com/image.jpg ]\n\n---\n\n🤖 Botpress:\n[ Card: "Cool Card" ]\nSubtitle: A nice subtitle\nImage: https://example.com/card.jpg\nurl: "Visit" => https://example.com'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe.concurrent('with custom message formatters', () => {
|
||||
it('should use custom formatters when provided', async () => {
|
||||
// Arrange
|
||||
const { client, ctx } = getMocks()
|
||||
const messages: Message[] = [
|
||||
{
|
||||
source: { type: 'user', userId: MOCK_USER.id },
|
||||
type: 'text',
|
||||
payload: { text: 'Hello' },
|
||||
},
|
||||
{
|
||||
source: { type: 'bot' },
|
||||
type: 'card',
|
||||
payload: {
|
||||
title: 'Card Title',
|
||||
actions: [],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const customMessageFormatters = {
|
||||
text: {
|
||||
formatMessage: (message: TextMessage) => [`CUSTOM TEXT: ${message.payload.text}`],
|
||||
} satisfies MessageFormatter<'text'>,
|
||||
}
|
||||
|
||||
// Act
|
||||
const transcript = buildConversationTranscript({
|
||||
messages,
|
||||
ctx,
|
||||
client,
|
||||
customMessageFormatters,
|
||||
})
|
||||
|
||||
// Assert
|
||||
await expect(transcript).resolves.toBe(
|
||||
'👤 John Doe:\nCUSTOM TEXT: Hello\n\n---\n\n🤖 Botpress:\n[ Card: "Card Title" ]'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe.concurrent('with custom transcript formatter', () => {
|
||||
it('should use custom transcript formatter when provided', async () => {
|
||||
// Arrange
|
||||
const { client, ctx } = getMocks()
|
||||
const messages: Message[] = [
|
||||
{
|
||||
source: { type: 'user', userId: MOCK_USER.id },
|
||||
type: 'text',
|
||||
payload: { text: 'Hello' },
|
||||
},
|
||||
{
|
||||
source: { type: 'bot' },
|
||||
type: 'text',
|
||||
payload: { text: 'Hi there' },
|
||||
},
|
||||
]
|
||||
|
||||
const customTranscriptFormatter: TranscriptFormatter = (extractedMessages) =>
|
||||
extractedMessages
|
||||
.map((message) => {
|
||||
const role = message.isBot ? 'BOT' : 'USER'
|
||||
return `[${role}] ${message.user.name}: ${message.text.join(' ')}`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
// Act
|
||||
const transcript = buildConversationTranscript({
|
||||
messages,
|
||||
ctx,
|
||||
client,
|
||||
customTranscriptFormatter,
|
||||
})
|
||||
|
||||
// Assert
|
||||
await expect(transcript).resolves.toBe('[USER] John Doe: Hello\n[BOT] Botpress: Hi there')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { UserResolverWithCaching } from '../user-resolver'
|
||||
import { MessageFormatter, MessageTextExtractor, MessageTextOutput } from './message-formatter'
|
||||
import { Message } from './message-types'
|
||||
|
||||
export type TranscriptFormatter = (extractedMessages: MessageTextOutput[]) => Promise<string> | string
|
||||
|
||||
export const buildConversationTranscript = async ({
|
||||
messages,
|
||||
ctx,
|
||||
client,
|
||||
customMessageFormatters,
|
||||
customTranscriptFormatter,
|
||||
}: {
|
||||
ctx: { botUserId: string }
|
||||
client: ConstructorParameters<typeof UserResolverWithCaching>[0]
|
||||
messages: Message[]
|
||||
customMessageFormatters?: { [K in Message['type']]?: MessageFormatter }
|
||||
customTranscriptFormatter?: TranscriptFormatter
|
||||
}): Promise<string> => {
|
||||
const messageExtractor = new MessageTextExtractor({
|
||||
botUserId: ctx.botUserId,
|
||||
customFormatters: customMessageFormatters,
|
||||
userResolver: new UserResolverWithCaching(client),
|
||||
})
|
||||
|
||||
const extractedMessages = await messageExtractor.extractTextFromMessages(messages)
|
||||
const transcriptFormatter = customTranscriptFormatter ?? DEFAULT_TRANSCRIPT_FORMATTER
|
||||
const transcript = await transcriptFormatter(extractedMessages)
|
||||
|
||||
return transcript
|
||||
}
|
||||
|
||||
const DEFAULT_TRANSCRIPT_FORMATTER: TranscriptFormatter = (extractedMessages) => {
|
||||
const formattedMessages = extractedMessages.map((message) => {
|
||||
const emoji = message.isBot ? '🤖' : '👤'
|
||||
const name = message.user.name ?? (message.isBot ? 'Botpress' : 'User')
|
||||
const header = `${emoji} ${name}:`
|
||||
const body = message.text.join('\n')
|
||||
|
||||
return `${header}\n${body}`
|
||||
})
|
||||
|
||||
return formattedMessages.join('\n\n---\n\n')
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type { MessageFormatter } from './message-formatter'
|
||||
export { buildConversationTranscript, type TranscriptFormatter } from './conversation-transcript'
|
||||
export type { Message } from './message-types'
|
||||
@@ -0,0 +1,562 @@
|
||||
import { it, describe, expect } from 'vitest'
|
||||
import { MessageTextExtractor, type MessageFormatter } from './message-formatter'
|
||||
import type {
|
||||
TextMessage,
|
||||
ImageMessage,
|
||||
FileMessage,
|
||||
LocationMessage,
|
||||
DropdownMessage,
|
||||
ChoiceMessage,
|
||||
CardMessage,
|
||||
MarkdownMessage,
|
||||
BlocMessage,
|
||||
CarouselMessage,
|
||||
Message,
|
||||
} from './message-types'
|
||||
|
||||
const MOCK_USER = {
|
||||
id: 'user-id',
|
||||
name: 'John Doe',
|
||||
tags: {},
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
}
|
||||
|
||||
const MOCK_BOT_USER = {
|
||||
id: 'bot-id',
|
||||
name: 'Botpress',
|
||||
tags: {},
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
}
|
||||
|
||||
const getMocks = () => ({
|
||||
userResolver: {
|
||||
getUser: async ({ id }: { id: string }) => ({
|
||||
user: id === MOCK_USER.id ? MOCK_USER : MOCK_BOT_USER,
|
||||
}),
|
||||
},
|
||||
botUserId: MOCK_BOT_USER.id,
|
||||
})
|
||||
|
||||
describe.concurrent('MessageTextExtractor', () => {
|
||||
describe.concurrent('source discrimination', () => {
|
||||
it('should correctly identify user messages', async () => {
|
||||
// Arrange
|
||||
const { userResolver, botUserId } = getMocks()
|
||||
const messageExtractor = new MessageTextExtractor({ userResolver, botUserId })
|
||||
const message = {
|
||||
source: { type: 'user', userId: MOCK_USER.id },
|
||||
type: 'text',
|
||||
payload: { text: 'Hello' },
|
||||
} satisfies TextMessage
|
||||
|
||||
// Act
|
||||
const extractedMessage = messageExtractor.extractTextFromMessage(message)
|
||||
|
||||
// Assert
|
||||
await expect(extractedMessage).resolves.toStrictEqual({
|
||||
user: MOCK_USER,
|
||||
isBot: false,
|
||||
text: ['Hello'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should correctly identify bot messages', async () => {
|
||||
// Arrange
|
||||
const { userResolver, botUserId } = getMocks()
|
||||
const messageExtractor = new MessageTextExtractor({ userResolver, botUserId })
|
||||
const message = {
|
||||
source: { type: 'bot' },
|
||||
type: 'text',
|
||||
payload: { text: 'I am a bot' },
|
||||
} satisfies TextMessage
|
||||
|
||||
// Act
|
||||
const extractedMessage = messageExtractor.extractTextFromMessage(message)
|
||||
|
||||
// Assert
|
||||
await expect(extractedMessage).resolves.toStrictEqual({
|
||||
user: MOCK_BOT_USER,
|
||||
isBot: true,
|
||||
text: ['I am a bot'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe.concurrent('when given a text message', () => {
|
||||
it('should normalize line endings', async () => {
|
||||
// Arrange
|
||||
const { userResolver, botUserId } = getMocks()
|
||||
const messageExtractor = new MessageTextExtractor({ userResolver, botUserId })
|
||||
const message = {
|
||||
source: { type: 'user', userId: MOCK_USER.id },
|
||||
type: 'text',
|
||||
payload: { text: 'Hello\r\nWorld ' },
|
||||
} satisfies TextMessage
|
||||
|
||||
// Act
|
||||
const extractedMessage = messageExtractor.extractTextFromMessage(message)
|
||||
|
||||
// Assert
|
||||
await expect(extractedMessage).resolves.toStrictEqual({
|
||||
user: MOCK_USER,
|
||||
isBot: false,
|
||||
text: ['Hello', 'World'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should extract text from multiple messages', async () => {
|
||||
// Arrange
|
||||
const { userResolver, botUserId } = getMocks()
|
||||
const messageExtractor = new MessageTextExtractor({ userResolver, botUserId })
|
||||
const messages: Message[] = [
|
||||
{
|
||||
source: { type: 'user', userId: MOCK_USER.id },
|
||||
type: 'text',
|
||||
payload: { text: 'Hello' },
|
||||
},
|
||||
{
|
||||
source: { type: 'bot' },
|
||||
type: 'text',
|
||||
payload: { text: 'Hi there!' },
|
||||
},
|
||||
]
|
||||
|
||||
// Act
|
||||
const extractedMessages = messageExtractor.extractTextFromMessages(messages)
|
||||
|
||||
// Assert
|
||||
await expect(extractedMessages).resolves.toStrictEqual([
|
||||
{
|
||||
user: MOCK_USER,
|
||||
isBot: false,
|
||||
text: ['Hello'],
|
||||
},
|
||||
{
|
||||
user: MOCK_BOT_USER,
|
||||
isBot: true,
|
||||
text: ['Hi there!'],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle empty text content', async () => {
|
||||
// Arrange
|
||||
const { userResolver, botUserId } = getMocks()
|
||||
const messageExtractor = new MessageTextExtractor({ userResolver, botUserId })
|
||||
const message = {
|
||||
source: { type: 'user', userId: MOCK_USER.id },
|
||||
type: 'text',
|
||||
payload: { text: '' },
|
||||
} satisfies TextMessage
|
||||
|
||||
// Act
|
||||
const extractedMessage = messageExtractor.extractTextFromMessage(message)
|
||||
|
||||
// Assert
|
||||
await expect(extractedMessage).resolves.toStrictEqual({
|
||||
user: MOCK_USER,
|
||||
isBot: false,
|
||||
text: [''],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe.concurrent('when given markdown messages', () => {
|
||||
it('should format markdown content correctly', async () => {
|
||||
// Arrange
|
||||
const { userResolver, botUserId } = getMocks()
|
||||
const messageExtractor = new MessageTextExtractor({ userResolver, botUserId })
|
||||
const message = {
|
||||
source: { type: 'bot' },
|
||||
type: 'markdown',
|
||||
payload: { markdown: '# Title\n\nSome **bold** text' },
|
||||
} satisfies MarkdownMessage
|
||||
|
||||
// Act
|
||||
const extractedMessage = messageExtractor.extractTextFromMessage(message)
|
||||
|
||||
// Assert
|
||||
await expect(extractedMessage).resolves.toStrictEqual({
|
||||
user: MOCK_BOT_USER,
|
||||
isBot: true,
|
||||
text: ['# Title', '', 'Some **bold** text'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe.concurrent('when given media messages', () => {
|
||||
it.each([
|
||||
{
|
||||
type: 'image',
|
||||
payload: { imageUrl: 'https://example.com/image.jpg' },
|
||||
expected: ['[ Image: https://example.com/image.jpg ]'],
|
||||
},
|
||||
{
|
||||
type: 'audio',
|
||||
payload: { audioUrl: 'https://example.com/audio.mp3' },
|
||||
expected: ['[ Audio: https://example.com/audio.mp3 ]'],
|
||||
},
|
||||
{
|
||||
type: 'video',
|
||||
payload: { videoUrl: 'https://example.com/video.mp4' },
|
||||
expected: ['[ Video: https://example.com/video.mp4 ]'],
|
||||
},
|
||||
])('should format $type messages correctly', async ({ type, payload, expected }) => {
|
||||
// Arrange
|
||||
const { userResolver, botUserId } = getMocks()
|
||||
const messageExtractor = new MessageTextExtractor({ userResolver, botUserId })
|
||||
const message = {
|
||||
source: { type: 'user', userId: MOCK_USER.id },
|
||||
type,
|
||||
payload,
|
||||
} as Message
|
||||
|
||||
// Act
|
||||
const extractedMessage = messageExtractor.extractTextFromMessage(message)
|
||||
|
||||
// Assert
|
||||
await expect(extractedMessage).resolves.toStrictEqual({
|
||||
user: MOCK_USER,
|
||||
isBot: false,
|
||||
text: expected,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe.concurrent('when given file messages', () => {
|
||||
it('should format file with title correctly', async () => {
|
||||
// Arrange
|
||||
const { userResolver, botUserId } = getMocks()
|
||||
const messageExtractor = new MessageTextExtractor({ userResolver, botUserId })
|
||||
const message = {
|
||||
source: { type: 'bot' },
|
||||
type: 'file',
|
||||
payload: {
|
||||
fileUrl: 'https://example.com/document.pdf',
|
||||
title: 'Important Document',
|
||||
},
|
||||
} satisfies FileMessage
|
||||
|
||||
// Act
|
||||
const extractedMessage = messageExtractor.extractTextFromMessage(message)
|
||||
|
||||
// Assert
|
||||
await expect(extractedMessage).resolves.toStrictEqual({
|
||||
user: MOCK_BOT_USER,
|
||||
isBot: true,
|
||||
text: ['[ File - Important Document: https://example.com/document.pdf ]'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should format file without title correctly', async () => {
|
||||
// Arrange
|
||||
const { userResolver, botUserId } = getMocks()
|
||||
const messageExtractor = new MessageTextExtractor({ userResolver, botUserId })
|
||||
const message = {
|
||||
source: { type: 'bot' },
|
||||
type: 'file',
|
||||
payload: {
|
||||
fileUrl: 'https://example.com/document.pdf',
|
||||
},
|
||||
} satisfies FileMessage
|
||||
|
||||
// Act
|
||||
const extractedMessage = messageExtractor.extractTextFromMessage(message)
|
||||
|
||||
// Assert
|
||||
await expect(extractedMessage).resolves.toStrictEqual({
|
||||
user: MOCK_BOT_USER,
|
||||
isBot: true,
|
||||
text: ['[ File - untitled: https://example.com/document.pdf ]'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe.concurrent('when given location messages', () => {
|
||||
it('should format location with all fields', async () => {
|
||||
// Arrange
|
||||
const { userResolver, botUserId } = getMocks()
|
||||
const messageExtractor = new MessageTextExtractor({ userResolver, botUserId })
|
||||
const message = {
|
||||
source: { type: 'user', userId: MOCK_USER.id },
|
||||
type: 'location',
|
||||
payload: {
|
||||
latitude: 40.7128,
|
||||
longitude: 74.006,
|
||||
title: 'New York City',
|
||||
address: '123 Broadway, NY',
|
||||
},
|
||||
} satisfies LocationMessage
|
||||
|
||||
// Act
|
||||
const extractedMessage = messageExtractor.extractTextFromMessage(message)
|
||||
|
||||
// Assert
|
||||
await expect(extractedMessage).resolves.toStrictEqual({
|
||||
user: MOCK_USER,
|
||||
isBot: false,
|
||||
text: ['[ Location ]', 'New York City', '123 Broadway, NY', '40.7128° N, 74.006° W'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should format location without optional fields', async () => {
|
||||
// Arrange
|
||||
const { userResolver, botUserId } = getMocks()
|
||||
const messageExtractor = new MessageTextExtractor({ userResolver, botUserId })
|
||||
const message = {
|
||||
source: { type: 'user', userId: MOCK_USER.id },
|
||||
type: 'location',
|
||||
payload: {
|
||||
latitude: 40.7128,
|
||||
longitude: 74.006,
|
||||
},
|
||||
} satisfies LocationMessage
|
||||
|
||||
// Act
|
||||
const extractedMessage = messageExtractor.extractTextFromMessage(message)
|
||||
|
||||
// Assert
|
||||
await expect(extractedMessage).resolves.toStrictEqual({
|
||||
user: MOCK_USER,
|
||||
isBot: false,
|
||||
text: ['[ Location ]', '40.7128° N, 74.006° W'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe.concurrent('when given interactive messages', () => {
|
||||
it('should format dropdown options', async () => {
|
||||
// Arrange
|
||||
const { userResolver, botUserId } = getMocks()
|
||||
const messageExtractor = new MessageTextExtractor({ userResolver, botUserId })
|
||||
const message = {
|
||||
source: { type: 'bot' },
|
||||
type: 'dropdown',
|
||||
payload: {
|
||||
text: 'Choose an option',
|
||||
options: [
|
||||
{ label: 'Option 1', value: 'opt1' },
|
||||
{ label: 'Option 2', value: 'opt2' },
|
||||
],
|
||||
},
|
||||
} satisfies DropdownMessage
|
||||
|
||||
// Act
|
||||
const extractedMessage = messageExtractor.extractTextFromMessage(message)
|
||||
|
||||
// Assert
|
||||
await expect(extractedMessage).resolves.toStrictEqual({
|
||||
user: MOCK_BOT_USER,
|
||||
isBot: true,
|
||||
text: ['[ Dropdown options ]', '"Option 1" (opt1)', '"Option 2" (opt2)'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should format choice options', async () => {
|
||||
// Arrange
|
||||
const { userResolver, botUserId } = getMocks()
|
||||
const messageExtractor = new MessageTextExtractor({ userResolver, botUserId })
|
||||
const message = {
|
||||
source: { type: 'bot' },
|
||||
type: 'choice',
|
||||
payload: {
|
||||
text: 'Select one',
|
||||
options: [
|
||||
{ label: 'Yes', value: 'yes' },
|
||||
{ label: 'No', value: 'no' },
|
||||
],
|
||||
},
|
||||
} satisfies ChoiceMessage
|
||||
|
||||
// Act
|
||||
const extractedMessage = messageExtractor.extractTextFromMessage(message)
|
||||
|
||||
// Assert
|
||||
await expect(extractedMessage).resolves.toStrictEqual({
|
||||
user: MOCK_BOT_USER,
|
||||
isBot: true,
|
||||
text: ['[ Choice options ]', '"Yes" (yes)', '"No" (no)'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should format card with all fields', async () => {
|
||||
// Arrange
|
||||
const { userResolver, botUserId } = getMocks()
|
||||
const messageExtractor = new MessageTextExtractor({ userResolver, botUserId })
|
||||
const message = {
|
||||
source: { type: 'bot' },
|
||||
type: 'card',
|
||||
payload: {
|
||||
title: 'Product Card',
|
||||
subtitle: 'Amazing product',
|
||||
imageUrl: 'https://example.com/product.jpg',
|
||||
actions: [
|
||||
{ action: 'url', label: 'Buy Now', value: 'https://shop.com' },
|
||||
{ action: 'postback', label: 'More Info', value: 'info_request' },
|
||||
],
|
||||
},
|
||||
} satisfies CardMessage
|
||||
|
||||
// Act
|
||||
const extractedMessage = messageExtractor.extractTextFromMessage(message)
|
||||
|
||||
// Assert
|
||||
await expect(extractedMessage).resolves.toStrictEqual({
|
||||
user: MOCK_BOT_USER,
|
||||
isBot: true,
|
||||
text: [
|
||||
'[ Card: "Product Card" ]',
|
||||
'Subtitle: Amazing product',
|
||||
'Image: https://example.com/product.jpg',
|
||||
'url: "Buy Now" => https://shop.com',
|
||||
'postback: "More Info" => info_request',
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should format card without optional fields', async () => {
|
||||
// Arrange
|
||||
const { userResolver, botUserId } = getMocks()
|
||||
const messageExtractor = new MessageTextExtractor({ userResolver, botUserId })
|
||||
const message = {
|
||||
source: { type: 'bot' },
|
||||
type: 'card',
|
||||
payload: {
|
||||
title: 'Simple Card',
|
||||
actions: [],
|
||||
},
|
||||
} satisfies CardMessage
|
||||
|
||||
// Act
|
||||
const extractedMessage = messageExtractor.extractTextFromMessage(message)
|
||||
|
||||
// Assert
|
||||
await expect(extractedMessage).resolves.toStrictEqual({
|
||||
user: MOCK_BOT_USER,
|
||||
isBot: true,
|
||||
text: ['[ Card: "Simple Card" ]'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe.concurrent('when given unhandled message types', () => {
|
||||
it('should ignore bloc message', async () => {
|
||||
// Arrange
|
||||
const { userResolver, botUserId } = getMocks()
|
||||
const messageExtractor = new MessageTextExtractor({ userResolver, botUserId })
|
||||
const message = {
|
||||
source: { type: 'bot' },
|
||||
type: 'bloc',
|
||||
payload: {
|
||||
items: [{ type: 'text', payload: { text: 'Hello World' } }],
|
||||
},
|
||||
} satisfies BlocMessage
|
||||
|
||||
// Act
|
||||
const extractedMessage = messageExtractor.extractTextFromMessage(message)
|
||||
|
||||
// Assert
|
||||
await expect(extractedMessage).resolves.toStrictEqual({
|
||||
user: MOCK_BOT_USER,
|
||||
isBot: true,
|
||||
text: ['[ Bloc ]'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore carousel message', async () => {
|
||||
// Arrange
|
||||
const { userResolver, botUserId } = getMocks()
|
||||
const messageExtractor = new MessageTextExtractor({ userResolver, botUserId })
|
||||
const message = {
|
||||
source: { type: 'bot' },
|
||||
type: 'carousel',
|
||||
payload: {
|
||||
items: [
|
||||
{
|
||||
title: 'Item 1',
|
||||
actions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies CarouselMessage
|
||||
|
||||
// Act
|
||||
const extractedMessage = messageExtractor.extractTextFromMessage(message)
|
||||
|
||||
// Assert
|
||||
await expect(extractedMessage).resolves.toStrictEqual({
|
||||
user: MOCK_BOT_USER,
|
||||
isBot: true,
|
||||
text: ['[ Carousel ]'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe.concurrent('with custom formatters', () => {
|
||||
it('should use provided custom formatters', async () => {
|
||||
// Arrange
|
||||
const { userResolver, botUserId } = getMocks()
|
||||
const customFormatters = {
|
||||
text: {
|
||||
formatMessage: (message: TextMessage) => [`CUSTOM: ${message.payload.text}`],
|
||||
} satisfies MessageFormatter<'text'>,
|
||||
}
|
||||
|
||||
const messageExtractor = new MessageTextExtractor({
|
||||
userResolver,
|
||||
botUserId,
|
||||
customFormatters,
|
||||
})
|
||||
|
||||
const message = {
|
||||
source: { type: 'user', userId: MOCK_USER.id },
|
||||
type: 'text',
|
||||
payload: { text: 'Hello' },
|
||||
} satisfies TextMessage
|
||||
|
||||
// Act
|
||||
const extractedMessage = messageExtractor.extractTextFromMessage(message)
|
||||
|
||||
// Assert
|
||||
await expect(extractedMessage).resolves.toStrictEqual({
|
||||
user: MOCK_USER,
|
||||
isBot: false,
|
||||
text: ['CUSTOM: Hello'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should use default formatters for types without custom formatters', async () => {
|
||||
// Arrange
|
||||
const { userResolver, botUserId } = getMocks()
|
||||
const customFormatters = {
|
||||
text: {
|
||||
formatMessage: (message: TextMessage) => [`CUSTOM: ${message.payload.text}`],
|
||||
} satisfies MessageFormatter<'text'>,
|
||||
}
|
||||
|
||||
const messageExtractor = new MessageTextExtractor({
|
||||
userResolver,
|
||||
botUserId,
|
||||
customFormatters,
|
||||
})
|
||||
|
||||
const message = {
|
||||
source: { type: 'bot' },
|
||||
type: 'image',
|
||||
payload: { imageUrl: 'https://example.com/image.jpg' },
|
||||
} satisfies ImageMessage
|
||||
|
||||
// Act
|
||||
const extractedMessage = messageExtractor.extractTextFromMessage(message)
|
||||
|
||||
// Assert
|
||||
await expect(extractedMessage).resolves.toStrictEqual({
|
||||
user: MOCK_BOT_USER,
|
||||
isBot: true,
|
||||
text: ['[ Image: https://example.com/image.jpg ]'],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { User } from '@botpress/client'
|
||||
import type { UserResolver } from '../user-resolver'
|
||||
import type { Message } from './message-types'
|
||||
|
||||
export type MessageTextOutput = {
|
||||
user: User
|
||||
isBot: boolean
|
||||
text: string[]
|
||||
}
|
||||
|
||||
export type MessageFormatter<
|
||||
TMessageType extends Message['type'] = Message['type'],
|
||||
TMessage extends Extract<Message, { type: TMessageType }> = Extract<Message, { type: TMessageType }>,
|
||||
> = {
|
||||
formatMessage(message: TMessage): Promise<string[]> | string[]
|
||||
}
|
||||
|
||||
export class MessageTextExtractor {
|
||||
private readonly _userResolver: UserResolver
|
||||
private readonly _botUserId: string
|
||||
private readonly _messageFormatters: { [K in Message['type']]: MessageFormatter }
|
||||
|
||||
public constructor({
|
||||
userResolver,
|
||||
botUserId,
|
||||
customFormatters,
|
||||
}: {
|
||||
userResolver: UserResolver
|
||||
botUserId: string
|
||||
customFormatters?: { [K in Message['type']]?: MessageFormatter }
|
||||
}) {
|
||||
this._userResolver = userResolver
|
||||
this._botUserId = botUserId
|
||||
|
||||
const _customFormatters = customFormatters ?? {}
|
||||
this._messageFormatters = {
|
||||
text: _customFormatters.text ?? DEFAULT_TEXT_FORMATTER,
|
||||
image: _customFormatters.image ?? DEFAULT_IMAGE_FORMATTER,
|
||||
audio: _customFormatters.audio ?? DEFAULT_AUDIO_FORMATTER,
|
||||
video: _customFormatters.video ?? DEFAULT_VIDEO_FORMATTER,
|
||||
file: _customFormatters.file ?? DEFAULT_FILE_FORMATTER,
|
||||
location: _customFormatters.location ?? DEFAULT_LOCATION_FORMATTER,
|
||||
dropdown: _customFormatters.dropdown ?? DEFAULT_DROPDOWN_FORMATTER,
|
||||
choice: _customFormatters.choice ?? DEFAULT_CHOICE_FORMATTER,
|
||||
card: _customFormatters.card ?? DEFAULT_CARD_FORMATTER,
|
||||
markdown: _customFormatters.markdown ?? DEFAULT_MARKDOWN_FORMATTER,
|
||||
bloc: _customFormatters.bloc ?? DEFAULT_BLOC_FORMATTER,
|
||||
carousel: _customFormatters.carousel ?? DEFAULT_CAROUSEL_FORMATTER,
|
||||
}
|
||||
}
|
||||
|
||||
public async extractTextFromMessages(messages: Message[]): Promise<MessageTextOutput[]> {
|
||||
return await Promise.all(messages.map(this.extractTextFromMessage.bind(this)))
|
||||
}
|
||||
|
||||
public async extractTextFromMessage(message: Message): Promise<MessageTextOutput> {
|
||||
const user = await this._getUser(message.source)
|
||||
const text = await this._formatMessageContent(message)
|
||||
|
||||
return { user, isBot: message.source.type === 'bot', text }
|
||||
}
|
||||
|
||||
private async _getUser(messageSource: Message['source']): Promise<User> {
|
||||
const { user } = await this._userResolver.getUser({
|
||||
id: messageSource.type === 'user' ? messageSource.userId : this._botUserId,
|
||||
})
|
||||
return user
|
||||
}
|
||||
|
||||
private async _formatMessageContent(message: Message): Promise<string[]> {
|
||||
const formatter = this._messageFormatters[message.type]
|
||||
const formattedMessage = await formatter.formatMessage(message)
|
||||
return formattedMessage
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_TEXT_FORMATTER: MessageFormatter<'text'> = {
|
||||
formatMessage: (message) => _normalizeLineEndings(message.payload.text).split('\n'),
|
||||
}
|
||||
|
||||
const DEFAULT_MARKDOWN_FORMATTER: MessageFormatter<'markdown'> = {
|
||||
formatMessage: (message) => _normalizeLineEndings(message.payload.markdown).split('\n'),
|
||||
}
|
||||
|
||||
const _normalizeLineEndings = (text: string): string => {
|
||||
return text.trim().replaceAll('\r\n', '\n')
|
||||
}
|
||||
|
||||
const DEFAULT_IMAGE_FORMATTER: MessageFormatter<'image'> = {
|
||||
formatMessage: (message) => [`[ Image: ${message.payload.imageUrl} ]`],
|
||||
}
|
||||
|
||||
const DEFAULT_AUDIO_FORMATTER: MessageFormatter<'audio'> = {
|
||||
formatMessage: (message) => [`[ Audio: ${message.payload.audioUrl} ]`],
|
||||
}
|
||||
|
||||
const DEFAULT_VIDEO_FORMATTER: MessageFormatter<'video'> = {
|
||||
formatMessage: (message) => [`[ Video: ${message.payload.videoUrl} ]`],
|
||||
}
|
||||
|
||||
const DEFAULT_FILE_FORMATTER: MessageFormatter<'file'> = {
|
||||
formatMessage: (message) => [`[ File - ${message.payload.title ?? 'untitled'}: ${message.payload.fileUrl} ]`],
|
||||
}
|
||||
|
||||
const DEFAULT_LOCATION_FORMATTER: MessageFormatter<'location'> = {
|
||||
formatMessage: (message) => {
|
||||
const parts = ['[ Location ]']
|
||||
|
||||
if (message.payload.title) {
|
||||
parts.push(message.payload.title)
|
||||
}
|
||||
|
||||
if (message.payload.address) {
|
||||
parts.push(..._normalizeLineEndings(message.payload.address).split('\n'))
|
||||
}
|
||||
|
||||
parts.push(`${message.payload.latitude}° N, ${message.payload.longitude}° W`)
|
||||
|
||||
return parts
|
||||
},
|
||||
}
|
||||
|
||||
const DEFAULT_DROPDOWN_FORMATTER: MessageFormatter<'dropdown'> = {
|
||||
formatMessage: (message) => ['[ Dropdown options ]', ...message.payload.options.map(_formatOption)],
|
||||
}
|
||||
|
||||
const DEFAULT_CHOICE_FORMATTER: MessageFormatter<'choice'> = {
|
||||
formatMessage: (message) => ['[ Choice options ]', ...message.payload.options.map(_formatOption)],
|
||||
}
|
||||
|
||||
const _formatOption = (option: { label: string; value: string }): string => {
|
||||
return `"${option.label}" (${option.value})`
|
||||
}
|
||||
|
||||
const DEFAULT_CARD_FORMATTER: MessageFormatter<'card'> = {
|
||||
formatMessage: (message) => {
|
||||
const parts = [`[ Card: "${message.payload.title}" ]`]
|
||||
|
||||
if (message.payload.subtitle) {
|
||||
parts.push(`Subtitle: ${message.payload.subtitle}`)
|
||||
}
|
||||
|
||||
if (message.payload.imageUrl) {
|
||||
parts.push(`Image: ${message.payload.imageUrl}`)
|
||||
}
|
||||
|
||||
if (message.payload.actions.length) {
|
||||
parts.push(...message.payload.actions.map(_formatAction))
|
||||
}
|
||||
|
||||
return parts
|
||||
},
|
||||
}
|
||||
|
||||
const _formatAction = (action: { action: string; label: string; value: string }): string => {
|
||||
return `${action.action}: "${action.label}" => ${action.value}`
|
||||
}
|
||||
|
||||
const DEFAULT_BLOC_FORMATTER: MessageFormatter<'bloc'> = {
|
||||
// Not implemented yet
|
||||
formatMessage: () => ['[ Bloc ]'],
|
||||
}
|
||||
|
||||
const DEFAULT_CAROUSEL_FORMATTER: MessageFormatter<'carousel'> = {
|
||||
// Not implemented yet
|
||||
formatMessage: () => ['[ Carousel ]'],
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
|
||||
type ValueOf<T> = T[keyof T]
|
||||
type Merge<A, B> = Omit<A, keyof B> & B
|
||||
|
||||
type AllMessageTypes = Merge<
|
||||
typeof sdk.messages.defaults,
|
||||
{
|
||||
markdown: typeof sdk.messages.markdown
|
||||
bloc: typeof sdk.messages.markdownBloc
|
||||
}
|
||||
>
|
||||
|
||||
export type Message = ValueOf<{
|
||||
[K in keyof AllMessageTypes]: {
|
||||
type: K
|
||||
source:
|
||||
| {
|
||||
type: 'user'
|
||||
userId: string
|
||||
}
|
||||
| {
|
||||
type: 'bot'
|
||||
}
|
||||
payload: sdk.z.infer<AllMessageTypes[K]['schema']>
|
||||
}
|
||||
}>
|
||||
|
||||
export type MessageSource = Message['source']
|
||||
|
||||
export type TextMessage = Extract<Message, { type: 'text' }>
|
||||
export type ImageMessage = Extract<Message, { type: 'image' }>
|
||||
export type AudioMessage = Extract<Message, { type: 'audio' }>
|
||||
export type VideoMessage = Extract<Message, { type: 'video' }>
|
||||
export type FileMessage = Extract<Message, { type: 'file' }>
|
||||
export type LocationMessage = Extract<Message, { type: 'location' }>
|
||||
export type CarouselMessage = Extract<Message, { type: 'carousel' }>
|
||||
export type CardMessage = Extract<Message, { type: 'card' }>
|
||||
export type DropdownMessage = Extract<Message, { type: 'dropdown' }>
|
||||
export type ChoiceMessage = Extract<Message, { type: 'choice' }>
|
||||
export type BlocMessage = Extract<Message, { type: 'bloc' }>
|
||||
export type MarkdownMessage = Extract<Message, { type: 'markdown' }>
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as client from '@botpress/client'
|
||||
import * as sdk from '@botpress/sdk'
|
||||
|
||||
// this type is not exported by the sdk: it's a placeholder for the integration type
|
||||
type BaseIntegration = sdk.DefaultIntegration<any>
|
||||
|
||||
type Cast<T, U> = T extends U ? T : U
|
||||
|
||||
/**
|
||||
* Creates or updates a user based on the provided input.
|
||||
* If a user is found with the same discriminating tags, it will be updated.
|
||||
* If no user is found, a new user will be created.
|
||||
*
|
||||
* Usage is similar to `client.getOrCreateUser`, but with the added ability to
|
||||
* update the user if it already exists.
|
||||
*/
|
||||
export const createOrUpdateUser = async <
|
||||
Client extends sdk.IntegrationSpecificClient<TIntegration>,
|
||||
TIntegration extends BaseIntegration = Client extends sdk.IntegrationSpecificClient<infer TI> ? TI : never,
|
||||
CreateProps extends Parameters<Client['createUser']>[0] = Parameters<Client['createUser']>[0],
|
||||
DiscriminatingTags extends Extract<keyof CreateProps['tags'], string> = Extract<keyof CreateProps['tags'], string>,
|
||||
>(
|
||||
props: CreateProps & { client: Client; discriminateByTags?: DiscriminatingTags[] }
|
||||
): Promise<Awaited<ReturnType<Client['createUser']>>> => {
|
||||
const { users: matchingUsers } = await props.client.listUsers({ tags: _getFilteredTags(props) })
|
||||
|
||||
const [firstUser, ...otherUsers] = matchingUsers
|
||||
if (otherUsers.length > 0) {
|
||||
throw new sdk.RuntimeError('Multiple users found with the same discriminating tags')
|
||||
}
|
||||
|
||||
type UserTags = keyof TIntegration['user']['tags']
|
||||
|
||||
if (firstUser) {
|
||||
const updateTags: Partial<Record<UserTags, string | null>> = { ...firstUser.tags, ...props.tags }
|
||||
return (await props.client.updateUser({
|
||||
...firstUser,
|
||||
...props,
|
||||
tags: updateTags as Cast<typeof updateTags, Record<string, string | null>>,
|
||||
})) as Awaited<ReturnType<Client['createUser']>>
|
||||
}
|
||||
|
||||
return (await props.client.createUser(props)) as Awaited<ReturnType<Client['createUser']>>
|
||||
}
|
||||
|
||||
const _getFilteredTags = <TAGS extends client.User['tags']>({
|
||||
tags,
|
||||
discriminateByTags,
|
||||
}: {
|
||||
tags: TAGS
|
||||
discriminateByTags?: string[]
|
||||
}): TAGS =>
|
||||
(discriminateByTags
|
||||
? Object.fromEntries(Object.entries(tags).filter(([key]) => discriminateByTags!.includes(key)))
|
||||
: tags) as TAGS
|
||||
@@ -0,0 +1 @@
|
||||
export * from './create-or-update-user'
|
||||
@@ -0,0 +1 @@
|
||||
export * from './try-catch-wrapper'
|
||||
@@ -0,0 +1,77 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { createAsyncFnWrapperWithErrorRedaction, createErrorHandlingDecorator } from './try-catch-wrapper'
|
||||
import { expect, test } from 'vitest'
|
||||
|
||||
const _errorRedactor = (error: Error, customMessage: string): RuntimeError =>
|
||||
new RuntimeError(`${error.message}: ${customMessage}`)
|
||||
|
||||
export const wrapAsyncFnWithTryCatch = createAsyncFnWrapperWithErrorRedaction(_errorRedactor)
|
||||
export const handleErrors = createErrorHandlingDecorator(wrapAsyncFnWithTryCatch)
|
||||
|
||||
export class MyClient {
|
||||
@handleErrors('hello1')
|
||||
public syncSuccessMethod() {
|
||||
return 'banana'
|
||||
}
|
||||
|
||||
@handleErrors('hello2')
|
||||
public syncFailureMethod() {
|
||||
throw new Error('oops')
|
||||
}
|
||||
|
||||
@handleErrors('hello3')
|
||||
public async asyncSuccessMethod() {
|
||||
return 'apple'
|
||||
}
|
||||
|
||||
@handleErrors('hello4')
|
||||
public async asyncFailureMethod() {
|
||||
throw new Error('oops')
|
||||
}
|
||||
}
|
||||
|
||||
test("decorating successful async methods shouldn't change their behavior", async () => {
|
||||
// Arrange
|
||||
const client = new MyClient()
|
||||
|
||||
// Act
|
||||
const promise = client.asyncSuccessMethod()
|
||||
|
||||
// Assert
|
||||
await expect(promise).resolves.toBe('apple')
|
||||
})
|
||||
|
||||
test('decorating failed async methods should redact the error', async () => {
|
||||
// Arrange
|
||||
const client = new MyClient()
|
||||
|
||||
// Act
|
||||
const promise = client.asyncFailureMethod()
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow(RuntimeError)
|
||||
await expect(promise).rejects.toThrow('oops: hello4')
|
||||
})
|
||||
|
||||
test("decorating successful sync methods shouldn't change their behaviour", () => {
|
||||
// Arrange
|
||||
const client = new MyClient()
|
||||
|
||||
// Act
|
||||
const result = client.syncSuccessMethod()
|
||||
|
||||
// Assert
|
||||
expect(result).toBe('banana')
|
||||
})
|
||||
|
||||
test('decorating failed sync methods should redact the error', () => {
|
||||
// Arrange
|
||||
const client = new MyClient()
|
||||
|
||||
// Act
|
||||
const call = () => client.syncFailureMethod()
|
||||
|
||||
// Assert
|
||||
expect(call).toThrow(RuntimeError)
|
||||
expect(call).toThrow('oops: hello2')
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
|
||||
type RedactFn = (originalError: Error, customErrorMessage: string) => sdk.RuntimeError
|
||||
type AnyFunction = (...args: any[]) => any
|
||||
|
||||
const _isPromise = (x: unknown): x is Promise<unknown> =>
|
||||
x instanceof Promise || (x !== null && typeof x === 'object' && 'then' in x && typeof x.then === 'function')
|
||||
|
||||
/**
|
||||
* Creates a wrapper function for asynchronous functions that catches errors and
|
||||
* redacts them using a provided redactor function.
|
||||
*
|
||||
* @param redactorFn - A function that redacts the original error and returns a `sdk.RuntimeError`.
|
||||
* @returns A function that takes an asynchronous function and a custom error message, and returns a wrapped version of the asynchronous function.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const redactorFn = (originalError: Error, customErrorMessage: string) => {
|
||||
* // ... Redact the error here ...
|
||||
* return new sdk.RuntimeError(redactedErrorMessage)
|
||||
* }
|
||||
*
|
||||
* const asyncFunction = async () => {
|
||||
* // Some async operation that might throw an error
|
||||
* }
|
||||
*
|
||||
* const tryCatchWrapper = createAsyncFnWrapperWithErrorRedaction(redactorFn)
|
||||
*
|
||||
* const wrappedAsyncFunction = tryCatchWrapper(asyncFunction, 'Custom error message')
|
||||
* ```
|
||||
*/
|
||||
export const createAsyncFnWrapperWithErrorRedaction =
|
||||
(redactorFn: RedactFn) =>
|
||||
/**
|
||||
* Wraps an async function with a try-catch block that catches any errors and
|
||||
* logs them as a `sdk.RuntimeError` after redacting them with a redactor
|
||||
* function to remove sensitive information.
|
||||
*
|
||||
* @param fn - The function to wrap
|
||||
* @param customErrorMessage - The error message to log
|
||||
* @returns A function identical to the input function, but wrapped with a try-catch block
|
||||
*/
|
||||
<WrappedFn extends AnyFunction>(fn: WrappedFn, customErrorMessage: string): WrappedFn => {
|
||||
return ((...args: Parameters<WrappedFn>): ReturnType<WrappedFn> => {
|
||||
let output: unknown
|
||||
try {
|
||||
output = fn(...args)
|
||||
} catch (thrown: unknown) {
|
||||
const originalError = thrown instanceof Error ? thrown : new Error(`${thrown}`)
|
||||
const runtimeError = redactorFn(originalError, customErrorMessage)
|
||||
throw runtimeError
|
||||
}
|
||||
|
||||
return (
|
||||
_isPromise(output)
|
||||
? output.catch(async (thrown: unknown) => {
|
||||
const originalError = thrown instanceof Error ? thrown : new Error(`${thrown}`)
|
||||
const runtimeError = redactorFn(originalError, customErrorMessage)
|
||||
throw runtimeError
|
||||
})
|
||||
: output
|
||||
) as ReturnType<WrappedFn>
|
||||
}) as WrappedFn
|
||||
}
|
||||
|
||||
/**
|
||||
* Default error redactor that logs the original error to the integration logs
|
||||
* and returns a generic error message to the user.
|
||||
*
|
||||
* @param error - The original error
|
||||
* @param customMessage - The custom error message that is returned to the user
|
||||
* @returns a `sdk.RuntimeError` with the custom error message
|
||||
*/
|
||||
export const defaultErrorRedactor: RedactFn = (error: Error, customMessage: string) => {
|
||||
// If the error is already a `sdk.RuntimeError`, we return it as-is, since it
|
||||
// should already have been redacted.
|
||||
|
||||
if (error instanceof sdk.RuntimeError) {
|
||||
return error
|
||||
}
|
||||
|
||||
// Otherwise, we proceed with redaction:
|
||||
// By default, we log the original error to the integration logs with a call
|
||||
// to `console.warn` and return a generic error message to the user, because
|
||||
// we cannot trust the original error message to be safe to expose, as it may
|
||||
// contain sensitive information.
|
||||
|
||||
// Integrations are free to provide their own redactor function if they want
|
||||
// to expose more information to the user or log the error differently.
|
||||
|
||||
console.warn(customMessage, error)
|
||||
return new sdk.RuntimeError(customMessage)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an error handling decorator that wraps a class method using a
|
||||
* try-catch handler created by `createAsyncFnWrapperWithErrorRedaction`.
|
||||
*
|
||||
* @param asyncFnWrapperWithErrorRedaction - The try-catch wrapper function to use
|
||||
* @returns A decorator function to wrap class methods to add error handling
|
||||
*/
|
||||
export const createErrorHandlingDecorator =
|
||||
(asyncFnWrapperWithErrorRedaction: ReturnType<typeof createAsyncFnWrapperWithErrorRedaction>) =>
|
||||
/**
|
||||
* Wraps a class method with a try-catch block that catches any errors and
|
||||
* applies the error redaction logic to them.
|
||||
*
|
||||
* @param errorMessage - A custom error message to log if an error occurs
|
||||
* @returns the original method, but wrapped with a try-catch block
|
||||
*/
|
||||
(errorMessage: string) =>
|
||||
(_target: unknown, _propertyKey: string, descriptor: PropertyDescriptor): void => {
|
||||
// Async generators are a special case:
|
||||
if (descriptor.value.constructor?.name === 'AsyncGeneratorFunction') {
|
||||
const _originalGenerator: (...args: unknown[]) => AsyncGenerator<unknown, void, unknown> = descriptor.value
|
||||
descriptor.value = async function* (...args: unknown[]) {
|
||||
try {
|
||||
yield* _originalGenerator.apply(this, args)
|
||||
} catch (thrown: unknown) {
|
||||
await asyncFnWrapperWithErrorRedaction(async () => {
|
||||
throw thrown
|
||||
}, `${errorMessage}: ${thrown}`)()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const _originalMethod: (...args: unknown[]) => Promise<unknown> = descriptor.value
|
||||
descriptor.value = function (...args: unknown[]) {
|
||||
return asyncFnWrapperWithErrorRedaction(_originalMethod.bind(this), errorMessage).apply(this, args)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default async function wrapper that uses {@link defaultErrorRedactor} to
|
||||
* convert thrown errors into `sdk.RuntimeError` instances with a custom message.
|
||||
*/
|
||||
export const wrapAsyncFnWithTryCatch = createAsyncFnWrapperWithErrorRedaction(defaultErrorRedactor)
|
||||
|
||||
/**
|
||||
* Default class method decorator that wraps the method with the default
|
||||
* try-catch wrapper.
|
||||
*/
|
||||
export const handleErrorsDecorator = createErrorHandlingDecorator(wrapAsyncFnWithTryCatch)
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as ButtonDialogPage } from './pages/button-dialog'
|
||||
export { default as SelectDialogPage } from './pages/select-dialog'
|
||||
export { default as InputDialogPage } from './pages/input-dialog'
|
||||
export { default as FormDialogPage } from './pages/form-dialog'
|
||||
@@ -0,0 +1,11 @@
|
||||
import dedent from 'dedent'
|
||||
import { marked } from 'marked'
|
||||
import * as preact from 'preact'
|
||||
|
||||
export default ({ children }: { children: preact.ComponentChild }) => (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: marked.parse(dedent(String(children ?? '')), { async: false, breaks: false, gfm: true }),
|
||||
}}
|
||||
></div>
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
import MarkdownDiv from '../markdown-div'
|
||||
|
||||
export default ({
|
||||
pageTitle,
|
||||
helpText,
|
||||
buttons,
|
||||
}: {
|
||||
pageTitle: string
|
||||
helpText: string
|
||||
buttons: ({
|
||||
label: string
|
||||
type?: 'primary' | 'secondary' | 'danger' | 'success' | 'warning' | 'info'
|
||||
} & ({ navigateTo: URL } | { closeWindow: true }))[]
|
||||
}) => {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
|
||||
<div style={{ maxWidth: 500, width: '100%' }}>
|
||||
<h1 className="text-center">{pageTitle}</h1>
|
||||
<MarkdownDiv>{helpText}</MarkdownDiv>
|
||||
<div style={{ columnGap: 5, display: 'flex', justifyContent: 'center' }}>
|
||||
{buttons.map((button) =>
|
||||
'navigateTo' in button ? (
|
||||
<a key={button.label} href={button.navigateTo.href} className={`btn btn-${button.type ?? 'primary'}`}>
|
||||
{button.label}
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
key={button.label}
|
||||
href="javascript:void(0);"
|
||||
// @ts-ignore
|
||||
// To allow interaction, not supported on SSR, use html attribute
|
||||
onclick="window.close()"
|
||||
className={`btn btn-${button.type ?? 'primary'}`}
|
||||
>
|
||||
{button.label}
|
||||
</a>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { FormFieldDescriptor } from '../../../oauth-wizard/schema-to-fields'
|
||||
import MarkdownDiv from '../markdown-div'
|
||||
|
||||
type FieldProps = { field: FormFieldDescriptor; fieldName: string; value?: string }
|
||||
|
||||
const FieldDescription = ({ description }: { description?: string }) =>
|
||||
description ? <div className="form-text">{description}</div> : null
|
||||
|
||||
const FieldError = ({ error, inline }: { error?: string; inline?: boolean }) =>
|
||||
error ? <div className={inline ? 'text-danger small mt-1' : 'invalid-feedback'}>{error}</div> : null
|
||||
|
||||
const BooleanField = ({ field, fieldName, value }: FieldProps) => {
|
||||
return (
|
||||
<div className="form-check mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
id={fieldName}
|
||||
name={fieldName}
|
||||
value="true"
|
||||
checked={value === 'true'}
|
||||
required={field.required}
|
||||
disabled={field.disabled}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor={fieldName}>
|
||||
{field.label}
|
||||
</label>
|
||||
<FieldDescription description={field.description} />
|
||||
<FieldError error={field.error} inline />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const EnumField = ({ field, fieldName, value }: FieldProps) => {
|
||||
const p = field.displayAsParams
|
||||
const options = field.options ?? []
|
||||
|
||||
if (field.inputType === 'radio') {
|
||||
return (
|
||||
<div className="form-group mb-3">
|
||||
<label className="form-label">{field.label}</label>
|
||||
{options.map((opt) => (
|
||||
<div key={opt.value} className="form-check">
|
||||
<input
|
||||
type="radio"
|
||||
className="form-check-input"
|
||||
id={`${fieldName}-${opt.value}`}
|
||||
name={fieldName}
|
||||
value={opt.value}
|
||||
checked={value === opt.value}
|
||||
required={field.required}
|
||||
disabled={field.disabled}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor={`${fieldName}-${opt.value}`}>
|
||||
{opt.label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
<FieldDescription description={field.description} />
|
||||
<FieldError error={field.error} inline />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="form-group mb-3">
|
||||
<label htmlFor={fieldName} className="form-label">
|
||||
{field.label}
|
||||
</label>
|
||||
<select
|
||||
className={`form-select${field.error ? ' is-invalid' : ''}`}
|
||||
id={fieldName}
|
||||
name={fieldName}
|
||||
required={field.required}
|
||||
disabled={field.disabled}
|
||||
multiple={p.multiple as boolean | undefined}
|
||||
size={p.size as number | undefined}
|
||||
>
|
||||
<option value="">{field.placeholder ?? 'Select...'}</option>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value} selected={value === opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<FieldDescription description={field.description} />
|
||||
<FieldError error={field.error} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TextField = ({ field, fieldName, value }: FieldProps) => {
|
||||
const p = field.displayAsParams
|
||||
return (
|
||||
<div className="form-group mb-3">
|
||||
<label htmlFor={fieldName} className="form-label">
|
||||
{field.label}
|
||||
</label>
|
||||
<textarea
|
||||
className={`form-control${field.error ? ' is-invalid' : ''}`}
|
||||
id={fieldName}
|
||||
name={fieldName}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
disabled={field.disabled}
|
||||
rows={p.rows as number | undefined}
|
||||
cols={p.cols as number | undefined}
|
||||
maxLength={p.maxLength as number | undefined}
|
||||
>
|
||||
{value}
|
||||
</textarea>
|
||||
<FieldDescription description={field.description} />
|
||||
<FieldError error={field.error} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const StringField = ({ field, fieldName, value }: FieldProps) => {
|
||||
const p = field.displayAsParams
|
||||
return (
|
||||
<div className="form-group mb-3">
|
||||
<label htmlFor={fieldName} className="form-label">
|
||||
{field.label}
|
||||
</label>
|
||||
<input
|
||||
type={field.inputType}
|
||||
className={`form-control${field.error ? ' is-invalid' : ''}`}
|
||||
id={fieldName}
|
||||
name={fieldName}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
disabled={field.disabled}
|
||||
value={value}
|
||||
min={p.min as string | number | undefined}
|
||||
max={p.max as string | number | undefined}
|
||||
step={(p.step ?? p.stepSize) as string | number | undefined}
|
||||
pattern={p.pattern as string | undefined}
|
||||
minLength={p.minLength as number | undefined}
|
||||
maxLength={p.maxLength as number | undefined}
|
||||
/>
|
||||
<FieldDescription description={field.description} />
|
||||
<FieldError error={field.error} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FormField = ({ field, paramPrefix }: { field: FormFieldDescriptor; paramPrefix: string }) => {
|
||||
const fieldName = `${paramPrefix}${field.name}`
|
||||
const value = field.previousValue ?? field.defaultValue
|
||||
|
||||
switch (field.inputType) {
|
||||
case 'checkbox':
|
||||
return <BooleanField field={field} fieldName={fieldName} value={value} />
|
||||
case 'radio':
|
||||
case 'select':
|
||||
return <EnumField field={field} fieldName={fieldName} value={value} />
|
||||
case 'textarea':
|
||||
return <TextField field={field} fieldName={fieldName} value={value} />
|
||||
default:
|
||||
return <StringField field={field} fieldName={fieldName} value={value} />
|
||||
}
|
||||
}
|
||||
|
||||
export default ({
|
||||
pageTitle,
|
||||
helpText,
|
||||
formSubmitUrl,
|
||||
formParamPrefix,
|
||||
fields,
|
||||
extraHiddenParams,
|
||||
}: {
|
||||
pageTitle: string
|
||||
helpText: string
|
||||
formSubmitUrl: URL
|
||||
formParamPrefix: string
|
||||
fields: FormFieldDescriptor[]
|
||||
extraHiddenParams: Record<string, string>
|
||||
}) => {
|
||||
return (
|
||||
<div className="d-flex justify-content-center align-items-center vh-100 overflow-hidden">
|
||||
<div className="d-flex flex-column w-100 py-4 mh-100" style={{ maxWidth: 500 }}>
|
||||
<div className="flex-shrink-0">
|
||||
<h1 className="text-center">{pageTitle}</h1>
|
||||
<MarkdownDiv>{helpText}</MarkdownDiv>
|
||||
</div>
|
||||
<form
|
||||
className="flex-grow-1 px-1 overflow-auto"
|
||||
id="wizard-form"
|
||||
action={formSubmitUrl.href}
|
||||
method="POST"
|
||||
style={{ minHeight: 0 }}
|
||||
>
|
||||
{Object.entries(extraHiddenParams).map(([key, value]) => (
|
||||
<input key={key} type="hidden" name={key} value={value} />
|
||||
))}
|
||||
{fields
|
||||
.filter((f) => !f.hidden)
|
||||
.map((field) => (
|
||||
<FormField key={field.name} field={field} paramPrefix={formParamPrefix} />
|
||||
))}
|
||||
|
||||
<button type="submit" className="btn btn-primary w-100 d-block">
|
||||
Submit
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import MarkdownDiv from '../markdown-div'
|
||||
|
||||
export default ({
|
||||
pageTitle,
|
||||
helpText,
|
||||
formSubmitUrl,
|
||||
formFieldName,
|
||||
inputConfig,
|
||||
extraHiddenParams,
|
||||
}: {
|
||||
pageTitle: string
|
||||
helpText: string
|
||||
formSubmitUrl: URL
|
||||
formFieldName: string
|
||||
inputConfig: {
|
||||
label: string
|
||||
type: 'text' | 'number' | 'email' | 'password' | 'url'
|
||||
}
|
||||
extraHiddenParams: Record<string, string>
|
||||
}) => {
|
||||
return (
|
||||
<div className="d-flex justify-content-center align-items-center vh-100">
|
||||
<div className="w-100" style={{ maxWidth: 500 }}>
|
||||
<h1 className="text-center">{pageTitle}</h1>
|
||||
<form action={formSubmitUrl.href} method="GET">
|
||||
{Object.entries(extraHiddenParams).map(([key, value]) => (
|
||||
<input key={key} type="hidden" name={key} value={value} />
|
||||
))}
|
||||
<div className="form-group mb-3">
|
||||
<MarkdownDiv>{helpText}</MarkdownDiv>
|
||||
<label htmlFor={formFieldName} className="form-label">
|
||||
{inputConfig.label}
|
||||
</label>
|
||||
<input type={inputConfig.type} className="form-control" id={formFieldName} name={formFieldName} required />
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary w-100 d-block">
|
||||
Submit
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import MarkdownDiv from '../markdown-div'
|
||||
|
||||
export default ({
|
||||
pageTitle,
|
||||
helpText,
|
||||
formSubmitUrl,
|
||||
formFieldName,
|
||||
options,
|
||||
extraHiddenParams,
|
||||
multiple,
|
||||
defaultValues,
|
||||
}: {
|
||||
pageTitle: string
|
||||
helpText: string
|
||||
formSubmitUrl: URL
|
||||
formFieldName: string
|
||||
options: { label: string; value: string }[]
|
||||
extraHiddenParams: Record<string, string>
|
||||
multiple?: boolean
|
||||
defaultValues?: string[]
|
||||
}) => {
|
||||
return (
|
||||
<div className="d-flex justify-content-center align-items-center vh-100">
|
||||
<div className="w-100" style={{ maxWidth: 500 }}>
|
||||
<h1 className="text-center">{pageTitle}</h1>
|
||||
<form action={formSubmitUrl.href} method="GET">
|
||||
{Object.entries(extraHiddenParams).map(([key, value]) => (
|
||||
<input type="hidden" name={key} value={value} />
|
||||
))}
|
||||
<div className="form-group mb-3">
|
||||
<MarkdownDiv>{helpText}</MarkdownDiv>
|
||||
<div className="mt-1">
|
||||
{options.map((option) => (
|
||||
<div key={option.value} className="form-check">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type={multiple ? 'checkbox' : 'radio'}
|
||||
id={option.value}
|
||||
name={formFieldName}
|
||||
value={option.value}
|
||||
checked={defaultValues?.includes(option.value)}
|
||||
></input>
|
||||
<label className="form-check-label" htmlFor={option.value}>
|
||||
{option.label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary w-100 d-block">
|
||||
Submit
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import dedent from 'dedent'
|
||||
import * as preact from 'preact-render-to-string'
|
||||
import { DISABLE_INTERSTITIAL_HEADER } from '../oauth-wizard'
|
||||
import { ButtonDialogPage, SelectDialogPage, InputDialogPage, FormDialogPage } from './components'
|
||||
|
||||
type CommonDialogProps = {
|
||||
pageTitle: string
|
||||
}
|
||||
|
||||
export const generateButtonDialog = (props: Parameters<typeof ButtonDialogPage>[0] & CommonDialogProps): sdk.Response =>
|
||||
_generateHtml({
|
||||
bodyHtml: preact.render(ButtonDialogPage(props)),
|
||||
pageTitle: props.pageTitle,
|
||||
})
|
||||
|
||||
export const generateSelectDialog = (props: Parameters<typeof SelectDialogPage>[0] & CommonDialogProps): sdk.Response =>
|
||||
_generateHtml({
|
||||
bodyHtml: preact.render(SelectDialogPage(props)),
|
||||
pageTitle: props.pageTitle,
|
||||
})
|
||||
|
||||
export const generateInputDialog = (props: Parameters<typeof InputDialogPage>[0] & CommonDialogProps): sdk.Response =>
|
||||
_generateHtml({
|
||||
bodyHtml: preact.render(InputDialogPage(props)),
|
||||
pageTitle: props.pageTitle,
|
||||
})
|
||||
|
||||
export const generateFormDialog = (props: Parameters<typeof FormDialogPage>[0] & CommonDialogProps): sdk.Response =>
|
||||
_generateHtml({
|
||||
bodyHtml: preact.render(FormDialogPage(props)),
|
||||
pageTitle: props.pageTitle,
|
||||
})
|
||||
|
||||
export const generateRawHtmlDialog = (props: { bodyHtml: string; pageTitle?: string }): sdk.Response =>
|
||||
_generateHtml({
|
||||
bodyHtml: props.bodyHtml,
|
||||
pageTitle: props.pageTitle,
|
||||
})
|
||||
|
||||
const _generateHtml = ({ bodyHtml, pageTitle }: { bodyHtml: string; pageTitle?: string }): sdk.Response => {
|
||||
return {
|
||||
headers: {
|
||||
'content-type': 'text/html',
|
||||
...DISABLE_INTERSTITIAL_HEADER,
|
||||
},
|
||||
body: dedent`
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${pageTitle || 'Botpress'}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css"
|
||||
integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65"
|
||||
rel="stylesheet" crossorigin="anonymous">
|
||||
<style>
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${bodyHtml}
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export * as llm from './llm'
|
||||
export * as speechToText from './speech-to-text'
|
||||
export * as textToImage from './text-to-image'
|
||||
export * from './integration-wrappers'
|
||||
export * from './error-handling'
|
||||
export * as cmd from './run-command'
|
||||
export * from './entity-helpers'
|
||||
export * from './collectable-async-generator'
|
||||
export * from './conversation-transcript'
|
||||
export * from './user-resolver'
|
||||
export * from './sandbox'
|
||||
export * as meta from './meta'
|
||||
export * from './markdown-transformer'
|
||||
export * from './posthog'
|
||||
@@ -0,0 +1,107 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
|
||||
// this type is not exported by the sdk: it's a placeholder for the integration type
|
||||
type BaseIntegration = never
|
||||
|
||||
type ValueOf<T> = T[Extract<keyof T, string>]
|
||||
|
||||
type CommonActionProps<
|
||||
IP extends sdk.IntegrationProps<TIntegration>,
|
||||
TIntegration extends BaseIntegration = IP extends sdk.IntegrationProps<infer TI> ? TI : never,
|
||||
> = Parameters<ValueOf<IP['actions']>>[0]
|
||||
type ToolFactory<
|
||||
ReturnType,
|
||||
IP extends sdk.IntegrationProps<TIntegration>,
|
||||
TIntegration extends BaseIntegration = IP extends sdk.IntegrationProps<infer TI> ? TI : never,
|
||||
> = (props: CommonActionProps<IP, TIntegration>) => ReturnType | Promise<ReturnType>
|
||||
|
||||
type InferToolsFromToolset<
|
||||
Toolset,
|
||||
IP extends sdk.IntegrationProps<TIntegration>,
|
||||
TIntegration extends BaseIntegration = IP extends sdk.IntegrationProps<infer TI> ? TI : never,
|
||||
> = {
|
||||
[Tool in Extract<keyof Toolset, string>]: Toolset[Tool] extends ToolFactory<infer ReturnType, IP, TIntegration>
|
||||
? Awaited<ReturnType>
|
||||
: never
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an action wrapper for handling the action logic of your itegration.
|
||||
*
|
||||
* @template IP - Use `bp.IntegrationProps` as the type parameter.
|
||||
*
|
||||
* @example
|
||||
* import * as bp from '.botpress'
|
||||
*
|
||||
* const wrapAction = createActionWrapper<bp.IntegrationProps>()({
|
||||
* toolFactories: {
|
||||
* apiClient: ({ ctx }) => new ApiClient(ctx),
|
||||
* },
|
||||
* extraMetadata: {} as {
|
||||
* errorMessage: string
|
||||
* },
|
||||
* })
|
||||
*
|
||||
* export default const integration = new bp.Integration({
|
||||
* actions: {
|
||||
* actionName: wrapAction(
|
||||
* { actionName: 'actionName', errorMessage: 'Failed to execute action' },
|
||||
* async ({ apiClient, input: { payload } }) => {
|
||||
* const { id } = await apiClient.doSomething(payload)
|
||||
*
|
||||
* return { id }
|
||||
* }
|
||||
* )
|
||||
* },
|
||||
* })
|
||||
*/
|
||||
export const createActionWrapper =
|
||||
<
|
||||
IP extends sdk.IntegrationProps<TIntegration>,
|
||||
TIntegration extends BaseIntegration = IP extends sdk.IntegrationProps<infer TI> ? TI : never,
|
||||
ACTIONS extends IP['actions'] = IP['actions'],
|
||||
>() =>
|
||||
<TOOLSET extends Record<string, ToolFactory<any, IP, TIntegration>>, EXTRAMETA extends Record<string, any> = {}>({
|
||||
toolFactories,
|
||||
extraMetadata: _,
|
||||
}: {
|
||||
toolFactories: TOOLSET
|
||||
extraMetadata?: EXTRAMETA
|
||||
}) =>
|
||||
/**
|
||||
* Wraps an action handler with the tools provided in the toolset.
|
||||
*
|
||||
* @param _metadata - The metadata of the action.
|
||||
* @param actionImpl - The implementation of the action handler.
|
||||
* This is a function that receives as its first parameter the generic props
|
||||
* for actions (ctx, client, logger, etc.), as well as the tools provided by
|
||||
* For example, if the toolset provides an `apiClient` tool, the action
|
||||
* implementation may access this tool by doing `props.apiClient`, or by
|
||||
* destructuring the props object.
|
||||
*/
|
||||
<ANAME extends Extract<keyof ACTIONS, string>, AFUNC extends ACTIONS[ANAME], APROPS extends Parameters<AFUNC>[0]>(
|
||||
_metadata: { actionName: ANAME } & EXTRAMETA,
|
||||
actionImpl: (
|
||||
props: APROPS & InferToolsFromToolset<TOOLSET, IP, TIntegration>,
|
||||
input: APROPS['input']
|
||||
) => VoidIfEmptyRecord<ReturnType<AFUNC>>
|
||||
): AFUNC =>
|
||||
(async (props: APROPS) => {
|
||||
const tools: Record<string, any> = {}
|
||||
for (const [tool, factory] of Object.entries(toolFactories)) {
|
||||
tools[tool] = await factory(props)
|
||||
}
|
||||
|
||||
return (
|
||||
(await actionImpl(
|
||||
{
|
||||
...props,
|
||||
...tools,
|
||||
} as APROPS & InferToolsFromToolset<TOOLSET, IP, TIntegration>,
|
||||
props.input
|
||||
)) ?? {}
|
||||
)
|
||||
}) as AFUNC
|
||||
|
||||
type IsEmptyRecord<T> = T extends Record<string, never> ? (keyof T extends never ? true : false) : false
|
||||
type VoidIfEmptyRecord<T extends Promise<any>> = IsEmptyRecord<Awaited<T>> extends true ? T | Promise<void> : T
|
||||
@@ -0,0 +1,102 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
|
||||
// this type is not exported by the sdk: it's a placeholder for the integration type
|
||||
type BaseIntegration = never
|
||||
|
||||
type ValueOf<T> = T[Extract<keyof T, string>]
|
||||
|
||||
type CommonChannelProps<
|
||||
IP extends sdk.IntegrationProps<TIntegration>,
|
||||
TIntegration extends BaseIntegration = IP extends sdk.IntegrationProps<infer TI> ? TI : never,
|
||||
> = Parameters<ValueOf<ValueOf<IP['channels']>['messages']>>[0]
|
||||
|
||||
type ToolFactory<
|
||||
ReturnType,
|
||||
IP extends sdk.IntegrationProps<TIntegration>,
|
||||
TIntegration extends BaseIntegration = IP extends sdk.IntegrationProps<infer TI> ? TI : never,
|
||||
> = (props: CommonChannelProps<IP, TIntegration>) => ReturnType | Promise<ReturnType>
|
||||
|
||||
type InferToolsFromToolset<
|
||||
Toolset,
|
||||
IP extends sdk.IntegrationProps<TIntegration>,
|
||||
TIntegration extends BaseIntegration = IP extends sdk.IntegrationProps<infer TI> ? TI : never,
|
||||
> = {
|
||||
[Tool in Extract<keyof Toolset, string>]: Toolset[Tool] extends ToolFactory<infer ReturnType, IP, TIntegration>
|
||||
? Awaited<ReturnType>
|
||||
: never
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a channel wrapper for handling the channel logic of your itegration.
|
||||
*
|
||||
* @template IP - Use `bp.IntegrationProps` as the type parameter.
|
||||
*
|
||||
* @example
|
||||
* import * as bp from '.botpress'
|
||||
*
|
||||
* const wrapChannel = createChannelWrapper<bp.IntegrationProps>()({
|
||||
* toolFactories: {
|
||||
* apiClient: ({ ctx }) => new ApiClient(ctx),
|
||||
* },
|
||||
* })
|
||||
*
|
||||
* export default const integration = new bp.Integration({
|
||||
* channels: {
|
||||
* channelName: {
|
||||
* messages: {
|
||||
* text: wrapChannel(
|
||||
* { channelName: 'channelName', messageType: 'text' },
|
||||
* async ({ ack, payload, apiClient }) => {
|
||||
* const newMsg = await apiClient.sendTextMessage(text)
|
||||
* await ack({ tags: { msgId: newMsg.id } })
|
||||
* }
|
||||
* )
|
||||
* }
|
||||
* }
|
||||
* },
|
||||
* })
|
||||
*/
|
||||
export const createChannelWrapper =
|
||||
<
|
||||
IP extends sdk.IntegrationProps<TIntegration>,
|
||||
TIntegration extends BaseIntegration = IP extends sdk.IntegrationProps<infer TI> ? TI : never,
|
||||
CHANNELS extends IP['channels'] = IP['channels'],
|
||||
>() =>
|
||||
<TOOLSET extends Record<string, ToolFactory<any, IP, TIntegration>>, EXTRAMETA extends Record<string, any> = {}>({
|
||||
toolFactories,
|
||||
extraMetadata: _,
|
||||
}: {
|
||||
toolFactories: TOOLSET
|
||||
extraMetadata?: EXTRAMETA
|
||||
}) =>
|
||||
/**
|
||||
* Wraps a channel message handler with the tools provided in the toolset.
|
||||
*
|
||||
* @param _metadata - The metadata of the channel.
|
||||
* @param channelImpl - The implementation of the channel message handler.
|
||||
* This is a function that receives as its first parameter the generic props
|
||||
* for channels (ctx, client, logger, ack, etc.), as well as the tools
|
||||
* provided by the toolset. For example, if the toolset provides an
|
||||
* `apiClient` tool, the channel implementation may access this tool by
|
||||
* doing `props.apiClient`, or by destructuring the props object.
|
||||
*/
|
||||
<
|
||||
CNAME extends Extract<keyof CHANNELS, string>,
|
||||
MTYPE extends Extract<keyof CHANNELS[CNAME]['messages'], string>,
|
||||
CFUNC extends CHANNELS[CNAME]['messages'][MTYPE],
|
||||
CFUNCPROPS extends Parameters<CFUNC>[0],
|
||||
>(
|
||||
_metadata: { channelName: CNAME; messageType: MTYPE } & EXTRAMETA,
|
||||
channelImpl: (props: CFUNCPROPS & InferToolsFromToolset<TOOLSET, IP, TIntegration>) => Promise<void>
|
||||
): CFUNC =>
|
||||
(async (props: CFUNCPROPS) => {
|
||||
const tools: Record<string, any> = {}
|
||||
for (const [tool, factory] of Object.entries(toolFactories)) {
|
||||
tools[tool] = await factory(props)
|
||||
}
|
||||
|
||||
await channelImpl({
|
||||
...props,
|
||||
...tools,
|
||||
} as CFUNCPROPS & InferToolsFromToolset<TOOLSET, IP, TIntegration>)
|
||||
}) as CFUNC
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './action-wrapper'
|
||||
export * from './channel-wrapper'
|
||||
@@ -0,0 +1,9 @@
|
||||
import { RuntimeError } from '@botpress/client'
|
||||
|
||||
const ERROR_SUBTYPE_UPSTREAM_PROVIDER_FAILED = 'UPSTREAM_PROVIDER_FAILED'
|
||||
|
||||
export function createUpstreamProviderFailedError(cause: Error, message?: string) {
|
||||
return new RuntimeError(message ?? cause.message, cause, undefined, {
|
||||
subtype: ERROR_SUBTYPE_UPSTREAM_PROVIDER_FAILED,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * as openai from './openai'
|
||||
export * as schemas from './schemas'
|
||||
export * from './types'
|
||||
export * from './errors'
|
||||
@@ -0,0 +1,464 @@
|
||||
import { InvalidPayloadError } from '@botpress/client'
|
||||
import { z, IntegrationLogger } from '@botpress/sdk'
|
||||
import assert from 'assert'
|
||||
import OpenAI from 'openai'
|
||||
import {
|
||||
ChatCompletion,
|
||||
ChatCompletionAssistantMessageParam,
|
||||
ChatCompletionContentPart,
|
||||
ChatCompletionContentPartImage,
|
||||
ChatCompletionContentPartText,
|
||||
ChatCompletionCreateParamsNonStreaming,
|
||||
ChatCompletionMessageParam,
|
||||
ChatCompletionMessageToolCall,
|
||||
ChatCompletionToolChoiceOption,
|
||||
ChatCompletionToolMessageParam,
|
||||
ChatCompletionUserMessageParam,
|
||||
} from 'openai/resources'
|
||||
import { ChatCompletionReasoningEffort } from 'openai/resources/chat/completions'
|
||||
import { createUpstreamProviderFailedError } from './errors'
|
||||
import { ReasoningEffort } from './schemas'
|
||||
import { GenerateContentInput, GenerateContentOutput, ToolCall, Message, ModelDetails } from './types'
|
||||
|
||||
const OpenAIErrorSchema = z
|
||||
.object({
|
||||
type: z.string(),
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
error: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
})
|
||||
.optional()
|
||||
.describe('Inner error'),
|
||||
failed_generation: z.string().optional(),
|
||||
})
|
||||
.strip() // IMPORTANT: This is so we can safely log the OpenAI error log as-is, to avoid leaking other sensitive information the error response may include.
|
||||
|
||||
export type OpenAIClient = OpenAI
|
||||
export async function generateContent<M extends string>(
|
||||
input: GenerateContentInput,
|
||||
openAIClient: OpenAIClient,
|
||||
logger: IntegrationLogger,
|
||||
props: {
|
||||
provider: string
|
||||
models: Record<M, ModelDetails>
|
||||
defaultModel: M
|
||||
overrideRequest?: (request: ChatCompletionCreateParamsNonStreaming) => ChatCompletionCreateParamsNonStreaming
|
||||
overrideResponse?: (
|
||||
response: OpenAI.Chat.Completions.ChatCompletion,
|
||||
request: ChatCompletionCreateParamsNonStreaming
|
||||
) => OpenAI.Chat.Completions.ChatCompletion
|
||||
}
|
||||
): Promise<GenerateContentOutput> {
|
||||
const modelId = (input.model?.id || props.defaultModel) as M
|
||||
const model = props.models[modelId]
|
||||
if (!model) {
|
||||
throw new InvalidPayloadError(
|
||||
`Model ID "${modelId}" is not allowed by this integration, supported model IDs are: ${Object.keys(
|
||||
props.models
|
||||
).join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
if (input.messages.length === 0 && !input.systemPrompt) {
|
||||
throw new InvalidPayloadError('At least one message or a system prompt is required')
|
||||
}
|
||||
|
||||
if (input.maxTokens && input.maxTokens > model.output.maxTokens) {
|
||||
throw new InvalidPayloadError(
|
||||
`maxTokens must be less than or equal to ${model.output.maxTokens} for model ID "${modelId}`
|
||||
)
|
||||
}
|
||||
|
||||
const messages: ChatCompletionMessageParam[] = []
|
||||
for (const message of input.messages) {
|
||||
messages.push(await mapToOpenAIMessage(message))
|
||||
}
|
||||
|
||||
if (input.systemPrompt) {
|
||||
messages.unshift({
|
||||
role: 'system',
|
||||
content: input.systemPrompt,
|
||||
})
|
||||
}
|
||||
|
||||
let maxTokens: number | undefined = undefined
|
||||
|
||||
if (input.maxTokens) {
|
||||
if (input.maxTokens <= model.output.maxTokens) {
|
||||
maxTokens = input.maxTokens
|
||||
} else {
|
||||
maxTokens = model.output.maxTokens
|
||||
logger
|
||||
.forBot()
|
||||
.warn(
|
||||
`Received maxTokens parameter greater than the maximum output tokens allowed for model "${modelId}", capping maxTokens to ${maxTokens}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let response: OpenAI.Chat.Completions.ChatCompletion | undefined
|
||||
|
||||
let request: ChatCompletionCreateParamsNonStreaming = {
|
||||
model: modelId,
|
||||
max_tokens: maxTokens,
|
||||
temperature: input.temperature,
|
||||
top_p: input.topP,
|
||||
response_format: input.responseFormat === 'json_object' ? { type: 'json_object' } : undefined,
|
||||
// TODO: the Studio is adding an empty item by default in the action input form
|
||||
stop: input.stopSequences?.filter((x) => x.trim()), // don't send empty values
|
||||
user: input.userId || undefined, // don't send a blank string value
|
||||
messages,
|
||||
tool_choice: mapToOpenAIToolChoice(input.toolChoice),
|
||||
tools: mapToOpenAITools(input.tools),
|
||||
}
|
||||
|
||||
if (props.overrideRequest) {
|
||||
request = props.overrideRequest(request)
|
||||
}
|
||||
|
||||
if (input.debug) {
|
||||
logger.forBot().info(`Request being sent to ${props.provider}: ` + JSON.stringify(request, null, 2))
|
||||
}
|
||||
|
||||
try {
|
||||
response = await openAIClient.chat.completions.create(request)
|
||||
} catch (err: any) {
|
||||
if (err instanceof OpenAI.APIError) {
|
||||
const parsedError = OpenAIErrorSchema.safeParse(err)
|
||||
if (parsedError.success) {
|
||||
if (input.debug) {
|
||||
logger.forBot().error(`Error received from ${props.provider}: ${JSON.stringify(parsedError.data, null, 2)}`)
|
||||
}
|
||||
|
||||
const message = `${props.provider} error ${err.status} (${err.type}:${err.code}): ${
|
||||
parsedError.data.error?.message ?? err.message
|
||||
}`
|
||||
|
||||
throw createUpstreamProviderFailedError(err, message)
|
||||
}
|
||||
}
|
||||
|
||||
throw createUpstreamProviderFailedError(err, `${props.provider} error: ${err.message}`)
|
||||
} finally {
|
||||
if (input.debug && response) {
|
||||
logger.forBot().info(`Response received from ${props.provider}: ` + JSON.stringify(response, null, 2))
|
||||
}
|
||||
}
|
||||
|
||||
if (props.overrideResponse) {
|
||||
response = props.overrideResponse(response, request)
|
||||
}
|
||||
|
||||
const inputTokens = response.usage?.prompt_tokens || 0
|
||||
const outputTokens = response.usage?.completion_tokens || 0
|
||||
|
||||
const inputCost = calculateTokenCost(model.input.costPer1MTokens, inputTokens)
|
||||
const outputCost = calculateTokenCost(model.output.costPer1MTokens, outputTokens)
|
||||
const cost = inputCost + outputCost
|
||||
|
||||
return {
|
||||
id: response.id,
|
||||
provider: props.provider,
|
||||
model: response.model,
|
||||
choices: response.choices.map((choice) => ({
|
||||
role: choice.message.role,
|
||||
type: 'text', // note: OpenAI only returns text messages (TODO: investigate response format for image generation)
|
||||
content: choice.message.content ?? null, // Some OpenAI-compatible providers (e.g. Cerebras) might not return a `content` at all (e.g. when doing a tool call) so we always fallback to null if it's not present.
|
||||
index: choice.index,
|
||||
stopReason: mapToStopReason(choice.finish_reason),
|
||||
toolCalls: mapToToolCalls(choice.message.tool_calls, logger, props.provider),
|
||||
})),
|
||||
usage: {
|
||||
inputTokens,
|
||||
inputCost,
|
||||
outputTokens,
|
||||
outputCost,
|
||||
},
|
||||
botpress: {
|
||||
cost, // DEPRECATED
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function calculateTokenCost(costPer1MTokens: number, tokenCount: number) {
|
||||
return (costPer1MTokens / 1_000_000) * tokenCount
|
||||
}
|
||||
|
||||
async function mapToOpenAIMessage(message: Message): Promise<ChatCompletionMessageParam> {
|
||||
const content = await mapToOpenAIMessageContent(message)
|
||||
|
||||
switch (message.role) {
|
||||
case 'assistant':
|
||||
if (message.type === 'tool_result') {
|
||||
if (!message.toolResultCallId) {
|
||||
throw new InvalidPayloadError('`toolResultCallId` is required when message type is "tool_result"')
|
||||
}
|
||||
|
||||
return <ChatCompletionToolMessageParam>{
|
||||
role: 'tool',
|
||||
tool_call_id: message.toolResultCallId,
|
||||
content,
|
||||
}
|
||||
} else if (message.type === 'tool_calls') {
|
||||
if (!message.toolCalls) {
|
||||
throw new InvalidPayloadError('`toolCalls` is required when message type is "tool_calls"')
|
||||
} else if (message.toolCalls.length === 0) {
|
||||
throw new InvalidPayloadError('`toolCalls` must contain at least one tool call')
|
||||
}
|
||||
|
||||
return <ChatCompletionAssistantMessageParam>{
|
||||
role: 'assistant',
|
||||
tool_calls: message.toolCalls.map((toolCall) => ({
|
||||
id: toolCall.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: toolCall.function.name,
|
||||
arguments: JSON.stringify(toolCall.function.arguments),
|
||||
},
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
return <ChatCompletionAssistantMessageParam>{
|
||||
role: 'assistant',
|
||||
content,
|
||||
}
|
||||
case 'user':
|
||||
default:
|
||||
return <ChatCompletionUserMessageParam>{
|
||||
role: 'user',
|
||||
content,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function mapToOpenAIMessageContent(message: Message) {
|
||||
if (message.type === 'tool_calls') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!message.content) {
|
||||
throw new InvalidPayloadError('`content` is required when message type is not "tool_calls"')
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case 'text':
|
||||
if (typeof message.content !== 'string') {
|
||||
throw new InvalidPayloadError('`content` must be a string when message type is "text"')
|
||||
}
|
||||
return message.content as string
|
||||
case 'tool_result':
|
||||
return message.content as string
|
||||
case 'multipart':
|
||||
if (!Array.isArray(message.content)) {
|
||||
throw new InvalidPayloadError('`content` must be an array when message type is "multipart"')
|
||||
}
|
||||
|
||||
return await mapMultipartMessageToOpenAIMessageParts(message.content)
|
||||
default:
|
||||
throw new InvalidPayloadError(`Message type "${message.type}" is not supported`)
|
||||
}
|
||||
}
|
||||
|
||||
async function mapMultipartMessageToOpenAIMessageParts(
|
||||
content: NonNullable<Message['content']>
|
||||
): Promise<ChatCompletionContentPart[]> {
|
||||
assert(typeof content !== 'string')
|
||||
|
||||
const parts: ChatCompletionContentPart[] = []
|
||||
|
||||
for (const contentPart of content) {
|
||||
switch (contentPart.type) {
|
||||
case 'text':
|
||||
if (!contentPart.text) {
|
||||
throw new InvalidPayloadError('`text` is required when part type is "text"')
|
||||
}
|
||||
|
||||
parts.push(<ChatCompletionContentPartText>{ type: 'text', text: contentPart.text })
|
||||
|
||||
break
|
||||
case 'image':
|
||||
if (!contentPart.url) {
|
||||
throw new InvalidPayloadError('`url` is required when part type is "image"')
|
||||
}
|
||||
|
||||
// Note: As of June 2024 it seems that OpenAI doesn't support image URLs directly (they return this error: "Expected a base64-encoded data URL with an image MIME type") contrary to what they say in their documentation, so we need to fetch the image and pass it as a data URI instead.
|
||||
let buffer: Buffer
|
||||
try {
|
||||
const response = await fetch(contentPart.url)
|
||||
buffer = Buffer.from(await response.arrayBuffer())
|
||||
|
||||
const contentTypeHeader = response.headers.get('content-type')
|
||||
if (!contentPart.mimeType && contentTypeHeader) {
|
||||
contentPart.mimeType = contentTypeHeader
|
||||
}
|
||||
} catch (err: any) {
|
||||
throw new InvalidPayloadError(
|
||||
`Failed to retrieve image in message content from the provided URL: ${contentPart.url} (Error: ${err.message})`
|
||||
)
|
||||
}
|
||||
|
||||
parts.push(<ChatCompletionContentPartImage>{
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: `data:${contentPart.mimeType};base64,${buffer.toString('base64')}`,
|
||||
detail: 'auto',
|
||||
},
|
||||
})
|
||||
|
||||
break
|
||||
default:
|
||||
throw new InvalidPayloadError(`Content type "${contentPart.type}" is not supported`)
|
||||
}
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
function mapToOpenAIToolChoice(
|
||||
toolChoice: GenerateContentInput['toolChoice']
|
||||
): ChatCompletionToolChoiceOption | undefined {
|
||||
if (!toolChoice) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
switch (toolChoice.type) {
|
||||
case '': // TODO: remove once Studio issue is fixed
|
||||
return undefined
|
||||
case 'none':
|
||||
return 'none'
|
||||
case 'auto':
|
||||
return 'auto'
|
||||
case 'any':
|
||||
return 'required'
|
||||
case 'specific':
|
||||
if (!toolChoice.functionName) {
|
||||
throw new InvalidPayloadError('`functionName` is required when `toolChoice` type is "specific"')
|
||||
}
|
||||
return { type: 'function', function: { name: toolChoice.functionName } }
|
||||
default:
|
||||
throw new InvalidPayloadError(`\`toolChoice\` value of "${toolChoice.type}" is not supported`)
|
||||
}
|
||||
}
|
||||
|
||||
function mapToOpenAITools(tools: GenerateContentInput['tools']) {
|
||||
const openAITools = tools?.map((tool) => ({
|
||||
type: tool.type,
|
||||
function: {
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
parameters: tool.function.argumentsSchema,
|
||||
},
|
||||
}))
|
||||
|
||||
// note: OpenAI doesn't allow an empty tools array
|
||||
return openAITools?.length ? openAITools : undefined
|
||||
}
|
||||
|
||||
function mapToStopReason(
|
||||
openAIFinishReason: ChatCompletion.Choice['finish_reason']
|
||||
): GenerateContentOutput['choices'][0]['stopReason'] {
|
||||
switch (openAIFinishReason) {
|
||||
case 'length':
|
||||
return 'max_tokens'
|
||||
case 'stop':
|
||||
return 'stop'
|
||||
case 'content_filter':
|
||||
return 'content_filter'
|
||||
case 'tool_calls':
|
||||
return 'tool_calls'
|
||||
default:
|
||||
return 'other'
|
||||
}
|
||||
}
|
||||
|
||||
function mapToToolCalls(
|
||||
openAIToolCalls: ChatCompletionMessageToolCall[] | undefined,
|
||||
logger: IntegrationLogger,
|
||||
provider: string
|
||||
): ToolCall[] | undefined {
|
||||
return openAIToolCalls?.reduce((toolCalls, openAIToolCall) => {
|
||||
if (openAIToolCall.type === 'function') {
|
||||
let toolCallArguments: ToolCall['function']['arguments']
|
||||
|
||||
try {
|
||||
toolCallArguments = JSON.parse(openAIToolCall.function.arguments)
|
||||
} catch (err) {
|
||||
logger
|
||||
.forBot()
|
||||
.warn(
|
||||
`Received invalid JSON from ${provider} for arguments in a generateContent tool call of function "${openAIToolCall.function.name}", a \`null\` value for arguments will be passed instead - JSON parser error: ${err} / Invalid JSON received: ${openAIToolCall.function.arguments}`
|
||||
)
|
||||
toolCallArguments = null
|
||||
}
|
||||
|
||||
toolCalls.push({
|
||||
id: openAIToolCall.id,
|
||||
type: openAIToolCall.type,
|
||||
function: {
|
||||
name: openAIToolCall.function.name,
|
||||
arguments: toolCallArguments,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
logger.forBot().warn(`Ignored unsupported tool call type "${openAIToolCall.type}" from ${provider}`)
|
||||
}
|
||||
|
||||
return toolCalls
|
||||
}, [] as ToolCall[])
|
||||
}
|
||||
|
||||
export function validateGptOssReasoningEffort(
|
||||
input: { reasoningEffort?: ReasoningEffort; model?: { id: string } },
|
||||
logger: IntegrationLogger
|
||||
): ChatCompletionReasoningEffort | undefined {
|
||||
if (input.reasoningEffort === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const GptOssSupportedReasoningEfforts: ChatCompletionReasoningEffort[] = ['low', 'medium', 'high']
|
||||
|
||||
if (input.reasoningEffort === 'none') {
|
||||
const acceptedValues = GptOssSupportedReasoningEfforts.map((x) => `"${x}"`)
|
||||
.map((x, i) => (i === GptOssSupportedReasoningEfforts.length - 1 ? `or ${x}` : x))
|
||||
.join(', ')
|
||||
throw new InvalidPayloadError(
|
||||
`Using "none" to disabling reasoning is not supported by ${input.model ? `the "${input.model?.id}" model` : 'this model'}, please use ${acceptedValues} instead or switch to a non-reasoning model`
|
||||
)
|
||||
}
|
||||
|
||||
if (GptOssSupportedReasoningEfforts.includes(input.reasoningEffort as any)) {
|
||||
return input.reasoningEffort as ChatCompletionReasoningEffort
|
||||
} else {
|
||||
const reasoningEffortOverride: ChatCompletionReasoningEffort = 'medium'
|
||||
logger
|
||||
.forBot()
|
||||
.info(
|
||||
`Reasoning effort "${input.reasoningEffort}" is not supported by ${input.model ? `the "${input.model?.id}" model` : 'this model'}, using "${reasoningEffortOverride}" effort instead`
|
||||
)
|
||||
return reasoningEffortOverride
|
||||
}
|
||||
}
|
||||
|
||||
export function validateOpenAIReasoningEffort(
|
||||
input: { reasoningEffort?: ReasoningEffort; model?: { id: string } },
|
||||
logger: IntegrationLogger
|
||||
): ChatCompletionReasoningEffort | undefined {
|
||||
if (input.reasoningEffort === 'none') {
|
||||
if (input.model?.id.startsWith('gpt-5.2-') || input.model?.id.startsWith('gpt-5.1-')) {
|
||||
return 'none'
|
||||
} else {
|
||||
logger
|
||||
.forBot()
|
||||
.warn(
|
||||
`Using "none" to disabling reasoning is not supported by the ${input.model?.id} model, falling back to "minimal" reasoning effort instead`
|
||||
)
|
||||
return 'minimal'
|
||||
}
|
||||
}
|
||||
|
||||
// Reasoning efforts supported by commercial OpenAI models are the same as the GPT-OSS models at the moment, so we reuse the same validation logic.
|
||||
return validateGptOssReasoningEffort(input, logger)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
import dedent from 'dedent'
|
||||
|
||||
export const ToolCallSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.enum(['function']),
|
||||
function: z.object({
|
||||
name: z.string(),
|
||||
arguments: z
|
||||
.record(z.any())
|
||||
.nullable()
|
||||
.describe('Some LLMs may generate invalid JSON for a tool call, so this will be `null` when it happens.'),
|
||||
}),
|
||||
})
|
||||
|
||||
export const ToolChoiceSchema = z.object({
|
||||
// TODO: remove empty value from enum once Studio issue is fixed
|
||||
type: z.enum(['auto', 'specific', 'any', 'none', '']).optional(), // note: Claude doesn't support "none" but we can simply strip out the tools when `type` is "none"
|
||||
functionName: z.string().optional().describe('Required if `type` is "specific"'),
|
||||
})
|
||||
|
||||
export const MessageSchema = z.object({
|
||||
role: z.enum(['user', 'assistant']),
|
||||
type: z.enum(['text', 'tool_calls', 'tool_result', 'multipart']).default('text'),
|
||||
toolCalls: z.array(ToolCallSchema).optional().describe('Required if `type` is "tool_calls"'),
|
||||
toolResultCallId: z.string().optional().describe('Required if `type` is "tool_result"'), // note: not supported by Gemini
|
||||
content: z
|
||||
.string()
|
||||
// TODO: union types are not supported yet by the Studio, comment this out when testing via an action card in the Studio
|
||||
.or(
|
||||
z.array(
|
||||
z.object({
|
||||
type: z.enum(['text', 'image']),
|
||||
mimeType: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Indicates the MIME type of the content. If not provided it will be detected from the content-type header of the provided URL.'
|
||||
),
|
||||
text: z.string().optional().describe('Required if part type is "text" '),
|
||||
url: z.string().optional().describe('Required if part type is "image"'),
|
||||
})
|
||||
)
|
||||
)
|
||||
// .optional() // cannot be optional because there is a bug in '@bpinternal/zui' that prevents it - Fleur
|
||||
.nullable()
|
||||
.describe(
|
||||
'Required unless `type` is "tool_call". If `type` is "multipart", this field must be an array of content objects. If `type` is "tool_result" then this field should be the result of the tool call (a plain string or a JSON-encoded array or object). If `type` is "tool_call" then the `toolCalls` field should be used instead.'
|
||||
),
|
||||
})
|
||||
|
||||
export const ModelRefSchema = z.object({
|
||||
id: z.string().title('LLM Model ID').describe('Unique identifier of the large language model'),
|
||||
})
|
||||
|
||||
export const ModelSchema = ModelRefSchema.extend({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
tags: z.array(
|
||||
z.enum([
|
||||
'recommended',
|
||||
'deprecated',
|
||||
'general-purpose',
|
||||
'low-cost',
|
||||
'vision',
|
||||
'coding',
|
||||
'agents',
|
||||
'function-calling',
|
||||
'roleplay',
|
||||
'storytelling',
|
||||
'reasoning',
|
||||
'preview',
|
||||
'speech-to-text',
|
||||
'image-generation',
|
||||
'text-to-speech',
|
||||
])
|
||||
),
|
||||
input: z.object({
|
||||
maxTokens: z.number().int(),
|
||||
costPer1MTokens: z.number().describe('Cost per 1 million tokens, in U.S. dollars'),
|
||||
}),
|
||||
output: z.object({
|
||||
maxTokens: z.number().int(),
|
||||
costPer1MTokens: z.number().describe('Cost per 1 million tokens, in U.S. dollars'),
|
||||
}),
|
||||
})
|
||||
|
||||
const ReasoningEffortSchema = z.enum(['low', 'medium', 'high', 'dynamic', 'none'])
|
||||
export type ReasoningEffort = z.infer<typeof ReasoningEffortSchema>
|
||||
|
||||
export const GenerateContentInputSchema = <S extends z.ZodSchema>(modelRefSchema: S) =>
|
||||
z.object({
|
||||
model: modelRefSchema.optional().describe('Model to use for content generation'),
|
||||
reasoningEffort: ReasoningEffortSchema.optional()
|
||||
.title('Reasoning Effort Level')
|
||||
.describe(
|
||||
dedent`
|
||||
Reasoning effort level to use for models that support reasoning. Specifying "none" will indicate the LLM to not use reasoning (for models that support optional reasoning). A "dynamic" effort will indicate the provider to automatically determine the reasoning effort (if supported by the provider). If not provided the model will not use reasoning for models with optional reasoning or use the default reasoning effort specified by the provider for reasoning-only models.
|
||||
Note: A higher reasoning effort will incur in higher output token charges from the LLM provider.
|
||||
`
|
||||
),
|
||||
systemPrompt: z.string().optional().title('System Prompt').describe('Optional system prompt to guide the model'),
|
||||
messages: z
|
||||
.array(MessageSchema)
|
||||
.title('Messages to Process')
|
||||
.describe('Array of messages for the model to process'),
|
||||
responseFormat: z
|
||||
.enum(['text', 'json_object'])
|
||||
.optional()
|
||||
.title('Response Format')
|
||||
.describe(
|
||||
'Response format expected from the model. If "json_object" is chosen, you must instruct the model to generate JSON either via the system prompt or a user message.'
|
||||
), // note: only OpenAI and Groq support this but for other models we can just append this as an indication in the system prompt
|
||||
// note: we don't support streaming yet
|
||||
maxTokens: z
|
||||
.number()
|
||||
.optional()
|
||||
.title('Maximum number of output tokens')
|
||||
.describe('Maximum number of tokens allowed in the generated response'),
|
||||
temperature: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(2)
|
||||
// @ts-ignore
|
||||
.displayAs({ id: 'slider', params: { stepSize: 0.01, horizontal: true } })
|
||||
.default(1)
|
||||
.title('Temperature')
|
||||
.describe('Sampling temperature for the model. Higher values result in more random outputs.'),
|
||||
topP: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(1)
|
||||
.default(1)
|
||||
// @ts-ignore
|
||||
.displayAs({ id: 'slider', params: { stepSize: 0.01, horizontal: true } })
|
||||
.title('Top-P')
|
||||
.describe(
|
||||
'Top-p sampling parameter. Limits sampling to the smallest set of tokens with a cumulative probability above the threshold.'
|
||||
), // TODO: .placeholder() from zui doesn't work, so we have to use .default() which introduces some typing issues
|
||||
// note: topK is supported by Claude and Gemini but not by OpenAI or Groq
|
||||
stopSequences: z
|
||||
.array(z.string())
|
||||
.max(4)
|
||||
.optional()
|
||||
.title('Stop Sequences')
|
||||
.describe('Sequences where the model should stop generating further tokens.'),
|
||||
tools: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.literal('function'),
|
||||
function: z.object({
|
||||
name: z.string().describe('Function name'),
|
||||
description: z.string().optional(),
|
||||
argumentsSchema: z.object({}).passthrough().optional().describe('JSON schema of the function arguments'),
|
||||
}),
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
.title('Tools')
|
||||
.describe('List of tools available for the model to use'),
|
||||
// TODO: an object with options doesn't seem to be supported by the Studio as it's not rendering correctly, the dropdown for "type" is not working and it's sending a blank value instead which causes a schema validation error unless an empty value is allowed in the `type` enum
|
||||
toolChoice: ToolChoiceSchema.optional()
|
||||
.title('Tool Choice')
|
||||
.describe('The chosen tool to use for content generation'), // note: Gemini doesn't support this but we can just ignore it there
|
||||
userId: z.string().optional().title('User ID').describe('Unique identifier of the user that sent the prompt'),
|
||||
debug: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.hidden()
|
||||
.title('Debug Mode')
|
||||
.describe('Set to `true` to output debug information to the bot logs'),
|
||||
meta: z
|
||||
.object({
|
||||
promptSource: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Source of the prompt, e.g. agent/:id/:version cards/ai-generate, cards/ai-task, nodes/autonomous, etc.'
|
||||
),
|
||||
promptCategory: z.string().optional(), // Deprecated, for backwards compatibility
|
||||
integrationName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Name of the integration that originally received the message that initiated this action'),
|
||||
})
|
||||
.optional()
|
||||
.hidden()
|
||||
.title('Prompt Metadata')
|
||||
.describe('Contextual metadata about the prompt'),
|
||||
})
|
||||
|
||||
export const GenerateContentInputBaseSchema = GenerateContentInputSchema(ModelRefSchema)
|
||||
|
||||
export const GenerateContentOutputSchema = z.object({
|
||||
id: z.string().title('Response ID').describe('Response ID from LLM provider'),
|
||||
provider: z.string().title('LLM Provider').describe('LLM provider name'),
|
||||
model: z.string().title('Model Name').describe('The name of the LLM model that was used'),
|
||||
choices: z
|
||||
.array(
|
||||
MessageSchema.omit({ role: true }).extend({
|
||||
role: z.literal('assistant'),
|
||||
index: z.number().int(),
|
||||
stopReason: z.enum(['stop', 'max_tokens', 'tool_calls', 'content_filter', 'other']),
|
||||
// note: stopSequence is supported by Claude but not by OpenAI, Groq or Gemini
|
||||
})
|
||||
)
|
||||
.title('Generated Choices')
|
||||
.describe('Array of generated message choices from the model'),
|
||||
usage: z
|
||||
.object({
|
||||
inputTokens: z.number().int().describe('Number of input tokens used by the model'),
|
||||
inputCost: z.number().describe('Cost of the input tokens received by the model, in U.S. dollars'),
|
||||
outputTokens: z.number().int().describe('Number of output tokens used by the model'),
|
||||
outputCost: z.number().describe('Cost of the output tokens generated by the model, in U.S. dollars'),
|
||||
})
|
||||
.title('Usage Information')
|
||||
.describe('A breakdown of token usage and cost information'),
|
||||
botpress: z
|
||||
.object({
|
||||
cost: z.number().title('Generation Cost').describe('Total cost of the content generation, in U.S. dollars'),
|
||||
})
|
||||
.title('Botpress Metadata')
|
||||
.describe('Metadata added by Botpress'),
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
import * as schemas from './schemas'
|
||||
|
||||
export type GenerateContentInput = z.infer<typeof schemas.GenerateContentInputBaseSchema>
|
||||
export type GenerateContentOutput = z.infer<typeof schemas.GenerateContentOutputSchema>
|
||||
export type ToolCall = z.infer<typeof schemas.ToolCallSchema>
|
||||
export type Message = z.infer<typeof schemas.MessageSchema>
|
||||
export type Model = z.infer<typeof schemas.ModelSchema>
|
||||
export type ModelDetails = Omit<Model, 'id'>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { transformMarkdown, stripAllHandlers } from './markdown-transformer'
|
||||
export { MarkdownHandlers } from './types'
|
||||
@@ -0,0 +1,226 @@
|
||||
import { test, expect, describe } from 'vitest'
|
||||
import { transformMarkdown, stripAllHandlers } from './markdown-transformer'
|
||||
import { MarkdownHandlers } from './types'
|
||||
|
||||
const FIXED_SIZE_SPACE_CHAR = '\u2002' // 'En space' yields better results for identation in WhatsApp messages
|
||||
|
||||
type Test = Record<string, { input: string; expected: string }>
|
||||
const stripAllTests: Test = {
|
||||
empty: { input: '', expected: '' },
|
||||
Text: { input: 'test', expected: 'test\n' },
|
||||
Paragraph: { input: 'first Paragraph\n\nSecond Paragraph', expected: 'first Paragraph\nSecond Paragraph\n' },
|
||||
Hard_line_break: { input: 'line one \nline two', expected: 'line one\nline two\n' },
|
||||
Title_1: { input: '# H1', expected: 'H1\n' },
|
||||
Title_2: { input: '## H2', expected: 'H2\n' },
|
||||
Title_3: { input: '### H3', expected: 'H3\n' },
|
||||
Ordered_list: { input: '1. orderedListItem1\n2. item2', expected: '1. orderedListItem1\n2. item2\n' },
|
||||
Unordered_list: { input: '- unorderedListItem1\n- item2', expected: '- unorderedListItem1\n- item2\n' },
|
||||
ThematicBreak: { input: 'horizontal\n\n---\n\nrule', expected: 'horizontal\n---\nrule\n' },
|
||||
Image: { input: '', expected: 'https://tinyurl.com/mrv4bmyk\n' },
|
||||
Image_with_fallback: { input: '', expected: 'https://tinyurl.com/mrv4bmyk\n' },
|
||||
Table: { input: '| 1 | 2 |\n| - | - |\n| a | b |', expected: '| 1 | 2 |\n| a | b |\n' },
|
||||
Footnote: { input: 'footnote[^1]\n\n[^1]: the footnote', expected: 'footnote[1]\n[1] the footnote\n' },
|
||||
Definition: { input: 'term\n: definition', expected: 'term\n: definition\n' },
|
||||
Task_list: { input: '- [x] taskListItem1\n- [ ] item2', expected: '☑︎ taskListItem1\n☐ item2\n' },
|
||||
Emoji_direct: { input: 'emoji direct 😂', expected: 'emoji direct 😂\n' },
|
||||
Link: { input: '[title](https://www.example.com)', expected: 'https://www.example.com\n' },
|
||||
Link_only: { input: 'https://www.example.com', expected: 'https://www.example.com\n' },
|
||||
Blockquote: { input: '> blockquote', expected: 'Quote: “blockquote”\n' },
|
||||
Code: { input: '`code`', expected: 'code\n' },
|
||||
Code_snippet: { input: '```\n{\n\ta: null\n}\n```', expected: '{\n\ta: null\n}\n' },
|
||||
Strong_with_asterisk: { input: '**bold-asterisk**', expected: 'bold-asterisk\n' },
|
||||
Strong_with_underscore: { input: '__bold-underscore__', expected: 'bold-underscore\n' },
|
||||
Emphasis_with_asterisk: { input: '*italic-asterisk*', expected: 'italic-asterisk\n' },
|
||||
Emphasis_with_underscore: { input: '_italic-underscore_', expected: 'italic-underscore\n' },
|
||||
Strong_emphasis: { input: '**_strong-emphasis_**', expected: 'strong-emphasis\n' },
|
||||
Strong_delete: { input: '**~strong-delete~**', expected: 'strong-delete\n' },
|
||||
Emphasis_delete: { input: '**_emphasis-delete_**', expected: 'emphasis-delete\n' },
|
||||
Strong_emphasis_delete: { input: '**_~strong-emphasis-delete~_**', expected: 'strong-emphasis-delete\n' },
|
||||
Delete: { input: '~~strikethrough~~', expected: 'strikethrough\n' },
|
||||
Link_in_list: {
|
||||
input: '- first\n- [title](https://www.example.com)',
|
||||
expected: '- first\n- https://www.example.com\n',
|
||||
},
|
||||
Image_in_list: {
|
||||
input: '- first\n- ',
|
||||
expected: '- first\n- https://tinyurl.com/mrv4bmyk\n',
|
||||
},
|
||||
Strong_in_list: {
|
||||
input: '- first\n- **strong_second**',
|
||||
expected: '- first\n- strong_second\n',
|
||||
},
|
||||
Unordered_sub_list: {
|
||||
input: '- first\n- second\n\t- sub-first\n\t- sub-second\n- third',
|
||||
expected: `- first\n- second\n${FIXED_SIZE_SPACE_CHAR}- sub-first\n${FIXED_SIZE_SPACE_CHAR}- sub-second\n- third\n`,
|
||||
},
|
||||
Ordered_sub_sub_list: {
|
||||
input: '1. root\n\t1. sub-one\n\t\t1. sub-two\n',
|
||||
expected: `1. root\n${FIXED_SIZE_SPACE_CHAR}1. sub-one\n${FIXED_SIZE_SPACE_CHAR.repeat(2)}1. sub-two\n`,
|
||||
},
|
||||
Mixed_sub_lists: {
|
||||
input: '- unordered\n\t1. ordered\n\t\t- [ ] task',
|
||||
expected: `- unordered\n${FIXED_SIZE_SPACE_CHAR}1. ordered\n${FIXED_SIZE_SPACE_CHAR.repeat(2)}☐ task\n`,
|
||||
},
|
||||
Complicated_lists: {
|
||||
input: '- unordered\n\t1. ordered\n- unordered\n\t1. ordered\n\t\t- [ ] task\n- unordered\n',
|
||||
expected: `- unordered\n${FIXED_SIZE_SPACE_CHAR}1. ordered\n- unordered\n${FIXED_SIZE_SPACE_CHAR}1. ordered\n${FIXED_SIZE_SPACE_CHAR.repeat(2)}☐ task\n- unordered\n`,
|
||||
},
|
||||
Emphasize_in_table: {
|
||||
input: '| 1 | 2 |\n| - | - |\n| a | _b_ |',
|
||||
expected: '| 1 | 2 |\n| a | b |\n',
|
||||
},
|
||||
Image_in_table: {
|
||||
input: '| 1 | 2 |\n| - | - |\n| a |  |',
|
||||
expected: '| 1 | 2 |\n| a | https://tinyurl.com/mrv4bmyk |\n',
|
||||
},
|
||||
Link_in_table: {
|
||||
input: '| 1 | 2 |\n| - | - |\n| a | [title](https://www.example.com) |',
|
||||
expected: '| 1 | 2 |\n| a | https://www.example.com |\n',
|
||||
},
|
||||
}
|
||||
|
||||
const bigInput = `# H1
|
||||
## H2
|
||||
### H3
|
||||
**bold-asterisk**
|
||||
*italic-asterisk*
|
||||
<!-- This comment will not appear in rendered output -->
|
||||
__bold-underscore__
|
||||
_italic-underscore_
|
||||
> blockquote
|
||||
1. orderedListItem1
|
||||
2. item2
|
||||
- unorderedListItem1
|
||||
- item2
|
||||
|
||||
\`code\`
|
||||
horizontal
|
||||
|
||||
---
|
||||
|
||||
rule
|
||||
[title](https://www.example.com)
|
||||

|
||||

|
||||
| 1 | 2 |
|
||||
| - | - |
|
||||
| a | b |
|
||||
\`\`\`
|
||||
{
|
||||
a: null
|
||||
}
|
||||
\`\`\`
|
||||
footnote[^1]
|
||||
|
||||
[^1]: the footnote
|
||||
|
||||
term
|
||||
: definition
|
||||
~~strikethrough~~
|
||||
- [x] taskListItem1
|
||||
- [ ] item2
|
||||
emoji direct 😂
|
||||
`
|
||||
|
||||
const expectedForBigInput = `H1
|
||||
H2
|
||||
H3
|
||||
bold-asterisk
|
||||
italic-asterisk
|
||||
bold-underscore
|
||||
italic-underscore
|
||||
Quote: “blockquote”
|
||||
1. orderedListItem1
|
||||
2. item2
|
||||
- unorderedListItem1
|
||||
- item2
|
||||
code
|
||||
horizontal
|
||||
---
|
||||
rule
|
||||
https://www.example.com
|
||||
https://tinyurl.com/mrv4bmyk
|
||||
https://tinyurl.com/mrv4bmyk
|
||||
| 1 | 2 |
|
||||
| a | b |
|
||||
{
|
||||
a: null
|
||||
}
|
||||
footnote[1]
|
||||
term
|
||||
: definition
|
||||
strikethrough
|
||||
☑︎ taskListItem1
|
||||
☐ item2
|
||||
emoji direct 😂
|
||||
[1] the footnote
|
||||
`
|
||||
|
||||
describe('Markdown Transformer', () => {
|
||||
test.each(Object.entries(stripAllTests))(
|
||||
'Single line test %s',
|
||||
(_testName: string, testValues: { input: string; expected: string }): void => {
|
||||
const actual = transformMarkdown(testValues.input)
|
||||
expect(actual).toBe(testValues.expected)
|
||||
}
|
||||
)
|
||||
|
||||
test('Multi-line multi markup test', () => {
|
||||
const actual = transformMarkdown(bigInput)
|
||||
expect(actual).toBe(expectedForBigInput)
|
||||
})
|
||||
|
||||
test('Custom heading handler markup test', () => {
|
||||
const expected = 'heading handled'
|
||||
const customHandlers: MarkdownHandlers = {
|
||||
...stripAllHandlers,
|
||||
heading: (_node, _visit) => {
|
||||
return expected
|
||||
},
|
||||
}
|
||||
|
||||
const actual = transformMarkdown('# any heading`', customHandlers)
|
||||
|
||||
expect(actual).toBe(expected)
|
||||
})
|
||||
|
||||
test('Custom blockQuote handler markup test', () => {
|
||||
const expected = 'quote handled'
|
||||
const customHandlers: MarkdownHandlers = {
|
||||
...stripAllHandlers,
|
||||
blockquote: (_node, _visit) => {
|
||||
return expected
|
||||
},
|
||||
}
|
||||
|
||||
const actual = transformMarkdown('> any quote\n> 1\n> 2\n>3', customHandlers)
|
||||
|
||||
expect(actual).toBe(expected)
|
||||
})
|
||||
|
||||
test('Custom paragraph handler markup test', () => {
|
||||
const expected = 'paragraph handled'
|
||||
const customHandlers: MarkdownHandlers = {
|
||||
...stripAllHandlers,
|
||||
paragraph: (_node, _visit) => expected,
|
||||
}
|
||||
|
||||
const actual = transformMarkdown('any paragraph', customHandlers)
|
||||
|
||||
expect(actual).toBe(expected)
|
||||
})
|
||||
|
||||
test('Escape character markup test', () => {
|
||||
const input = '\\# not handled'
|
||||
const expected = '# not handled'
|
||||
const customHandlers: MarkdownHandlers = {
|
||||
...stripAllHandlers,
|
||||
heading: (_node, _visit) => 'title handled',
|
||||
paragraph: (_node, _visit) => _visit(_node),
|
||||
}
|
||||
|
||||
const actual = transformMarkdown(input, customHandlers)
|
||||
|
||||
expect(actual).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import { List, Parent, Root, Table } from 'mdast'
|
||||
import { remark } from 'remark'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { ExtendedList, ExtendedListItem, ExtendedTableRow, MarkdownHandlers, NodeHandler } from './types'
|
||||
|
||||
/** 'En space' yields better results for indentation in WhatsApp messages */
|
||||
const FIXED_SIZE_SPACE_CHAR = '\u2002'
|
||||
|
||||
export const stripAllHandlers: MarkdownHandlers = {
|
||||
blockquote: (node, visit) => `Quote: “${visit(node)}”\n`,
|
||||
break: (_node, _visit) => '\n',
|
||||
code: (node, _visit) => `${node.value}\n`,
|
||||
delete: (node, visit) => `${visit(node)}`,
|
||||
emphasis: (node, visit) => visit(node),
|
||||
footnoteDefinition: (node, visit) => `[${node.identifier}] ${visit(node)}\n`,
|
||||
footnoteReference: (node, _visit) => `[${node.identifier}]`,
|
||||
heading: (node, visit) => `${visit(node)}\n`,
|
||||
html: (_node, _visit) => '',
|
||||
image: (node, _visit) => node.url,
|
||||
inlineCode: (node, _visit) => node.value,
|
||||
link: (node, _visit) => node.url,
|
||||
list: (node, visit) => {
|
||||
return `${node.listLevel !== 1 ? '\n' : ''}${visit(node)}`
|
||||
},
|
||||
listItem: (node, visit) => {
|
||||
const { itemCount, checked, ownerList } = node
|
||||
let prefix = FIXED_SIZE_SPACE_CHAR.repeat(ownerList.listLevel - 1)
|
||||
|
||||
if (checked !== null) {
|
||||
prefix += checked ? '☑︎ ' : '☐ '
|
||||
} else {
|
||||
prefix += ownerList.ordered === true ? `${itemCount}. ` : '- '
|
||||
}
|
||||
|
||||
const shouldBreak = ownerList.listLevel === 1 || itemCount < ownerList.children.length
|
||||
return `${prefix}${visit(node)}${shouldBreak ? '\n' : ''}`
|
||||
},
|
||||
paragraph: (node, visit, parents) => `${visit(node)}${parents.at(-1)?.type === 'root' ? '\n' : ''}`,
|
||||
strong: (node, visit) => visit(node),
|
||||
table: (node, visit) => visit(node),
|
||||
tableRow: (node, visit) => `${visit(node)}\n`,
|
||||
tableCell: (node, visit) => {
|
||||
const prefix = node.isFirst ? '| ' : ''
|
||||
const suffix = node.isLast ? ' |' : ' | '
|
||||
return `${prefix}${visit(node)}${suffix}`
|
||||
},
|
||||
text: (node, _visit) => node.value,
|
||||
thematicBreak: (_node, _visit) => '---\n',
|
||||
}
|
||||
|
||||
const _applyListLevelAndItemIndices = (listNode: List, parents: Parent[]): ExtendedList => {
|
||||
const extendedList = listNode as ExtendedList
|
||||
|
||||
const listLevel = parents.filter((parent) => parent.type === 'list').length + 1
|
||||
extendedList.listLevel = listLevel
|
||||
|
||||
let index = 0
|
||||
for (const item of listNode.children) {
|
||||
index++
|
||||
const extendedItem = item as ExtendedListItem
|
||||
extendedItem.ownerList = extendedList
|
||||
extendedItem.itemCount = index
|
||||
}
|
||||
|
||||
return extendedList
|
||||
}
|
||||
|
||||
const _applyExtendedTableProps = (tableNode: Table): Table => {
|
||||
tableNode.children.forEach((row, index) => {
|
||||
const extendedRow = row as ExtendedTableRow
|
||||
const isHeaderRow = index === 0
|
||||
extendedRow.isHeader = isHeaderRow
|
||||
|
||||
const numOfCells = extendedRow.children.length
|
||||
extendedRow.children.forEach((cell, index) => {
|
||||
cell.isHeader = isHeaderRow
|
||||
cell.isFirst = index === 0
|
||||
cell.isLast = index === numOfCells - 1
|
||||
})
|
||||
})
|
||||
|
||||
return tableNode
|
||||
}
|
||||
|
||||
const _isNodeType = (s: string, handlers: MarkdownHandlers): s is keyof MarkdownHandlers => s in handlers
|
||||
|
||||
export const visitTree = (
|
||||
tree: Parent,
|
||||
handlers: MarkdownHandlers,
|
||||
parents: Parent[],
|
||||
data: Record<string, unknown>
|
||||
): string => {
|
||||
let tmp = ''
|
||||
let footnoteTmp = ''
|
||||
parents.push(tree)
|
||||
for (const node of tree.children) {
|
||||
if (!_isNodeType(node.type, handlers)) {
|
||||
throw new Error(`The Markdown node type [${node.type}] is not supported`)
|
||||
}
|
||||
|
||||
const handler = handlers[node.type] as NodeHandler
|
||||
const visitHandler = (n: Parent) => visitTree(n, handlers, parents, data)
|
||||
|
||||
switch (node.type) {
|
||||
case 'list':
|
||||
const listNode = _applyListLevelAndItemIndices(node, parents)
|
||||
tmp += handler(listNode, visitHandler, parents, handlers, data)
|
||||
break
|
||||
case 'listItem':
|
||||
node.checked = node.checked ?? null
|
||||
tmp += handler(node, visitHandler, parents, handlers, data)
|
||||
break
|
||||
case 'table':
|
||||
const extendedTableNode = _applyExtendedTableProps(node)
|
||||
tmp += handler(extendedTableNode, visitHandler, parents, handlers, data)
|
||||
break
|
||||
case 'footnoteDefinition':
|
||||
footnoteTmp += handler(node, visitHandler, parents, handlers, data)
|
||||
break
|
||||
default:
|
||||
tmp += handler(node, visitHandler, parents, handlers, data)
|
||||
break
|
||||
}
|
||||
}
|
||||
parents.pop()
|
||||
return `${tmp}${footnoteTmp}`
|
||||
}
|
||||
|
||||
export const transformMarkdown = (
|
||||
markdown: string,
|
||||
handlers: MarkdownHandlers = stripAllHandlers,
|
||||
preProcessor?: (root: Root) => Record<string, unknown>
|
||||
): string => {
|
||||
const tree = remark().use(remarkGfm).parse(markdown)
|
||||
const data = preProcessor?.(tree) ?? {}
|
||||
return visitTree(tree, handlers, [], data)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { List, ListItem, Node, TableCell, Nodes, TableRow, Parent } from 'mdast'
|
||||
|
||||
export type Merge<T, R> = Omit<T, keyof R> & R
|
||||
|
||||
export type NodeHandler<Type extends Node | Nodes['type'] = Nodes['type']> = (
|
||||
node: Type extends Node ? Type : Extract<Nodes, { type: Type }>,
|
||||
visit: (node: Parent) => string,
|
||||
parents: Parent[],
|
||||
handlers: MarkdownHandlers,
|
||||
state: Record<string, unknown>
|
||||
) => string
|
||||
|
||||
export type MarkdownHandlers = Partial<
|
||||
Merge<
|
||||
{ [Type in Nodes['type']]: NodeHandler<Type> },
|
||||
{
|
||||
listItem: NodeHandler<ExtendedListItem>
|
||||
list: NodeHandler<ExtendedList>
|
||||
tableRow: NodeHandler<ExtendedTableRow>
|
||||
tableCell: NodeHandler<ExtendedTableCell>
|
||||
}
|
||||
>
|
||||
>
|
||||
|
||||
// ===== Node Property Extensions ====
|
||||
export type ExtendedList = Merge<
|
||||
List,
|
||||
{
|
||||
listLevel: number
|
||||
children: ExtendedListItem[]
|
||||
}
|
||||
>
|
||||
export type ExtendedListItem = ListItem & {
|
||||
checked: boolean | null
|
||||
/** One Based Index */
|
||||
itemCount: number
|
||||
ownerList: ExtendedList
|
||||
}
|
||||
export type ExtendedTableRow = Merge<
|
||||
TableRow,
|
||||
{
|
||||
isHeader?: boolean
|
||||
children: ExtendedTableCell[]
|
||||
}
|
||||
>
|
||||
export type ExtendedTableCell = TableCell & {
|
||||
isHeader?: boolean
|
||||
/** If it's the first cell in the row */
|
||||
isFirst: boolean
|
||||
/** If it's the last cell in the row */
|
||||
isLast: boolean
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { handler as subscribeHandler } from './subscribe-handler'
|
||||
export { validateRequestSignature } from './validate-request-signature'
|
||||
@@ -0,0 +1,29 @@
|
||||
import { IntegrationProps } from '@botpress/sdk'
|
||||
|
||||
type IntegrationHandler = IntegrationProps['handler']
|
||||
type IntegrationHandlerProps = Pick<Parameters<IntegrationHandler>[0], 'req'>
|
||||
|
||||
export const handler = async (props: IntegrationHandlerProps & { verifyToken: string }) => {
|
||||
const { req } = props
|
||||
|
||||
const queryParams = new URLSearchParams(req.query)
|
||||
const mode = queryParams.get('hub.mode')
|
||||
if (mode !== 'subscribe') {
|
||||
return { status: 400, body: "Mode should be set to 'subscribe'" }
|
||||
}
|
||||
|
||||
const token = queryParams.get('hub.verify_token')
|
||||
const challenge = queryParams.get('hub.challenge')
|
||||
if (!token || !challenge) {
|
||||
return { status: 400, body: 'Missing required query parameters' }
|
||||
}
|
||||
|
||||
if (token !== props.verifyToken) {
|
||||
return { status: 403, body: 'Invalid verify token' }
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: challenge,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { IntegrationProps } from '@botpress/sdk'
|
||||
import crypto from 'crypto'
|
||||
|
||||
type IntegrationHandler = IntegrationProps['handler']
|
||||
type IntegrationHandlerProps = Pick<Parameters<IntegrationHandler>[0], 'req'>
|
||||
|
||||
export const validateRequestSignature = async ({
|
||||
req,
|
||||
clientSecret,
|
||||
}: IntegrationHandlerProps & { clientSecret?: string }) => {
|
||||
if (!clientSecret) {
|
||||
return { error: false }
|
||||
}
|
||||
|
||||
const expectedSignature = crypto
|
||||
.createHmac('sha256', clientSecret)
|
||||
.update(req.body ?? '')
|
||||
.digest('hex')
|
||||
const signature = req.headers['x-hub-signature-256']?.split('=')[1]
|
||||
if (signature !== expectedSignature) {
|
||||
return { error: true, message: `Invalid signature (got ${signature ?? 'none'}, expected ${expectedSignature})` }
|
||||
}
|
||||
|
||||
return { error: false }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export const DISABLE_INTERSTITIAL_HEADER = { 'x-bp-disable-interstitial': 'true' } as const
|
||||
export const BASE_WIZARD_PATH = '/oauth/wizard/'
|
||||
export const CHOICE_PARAM = 'wizchoice'
|
||||
export const INPUT_PARAM = 'wizinput'
|
||||
export const FORM_PARAM_PREFIX = 'wizform.'
|
||||
export const FORM_SCHEMA_PARAM = '__wizform_schema__'
|
||||
@@ -0,0 +1,7 @@
|
||||
export { OAuthWizardBuilder } from './wizard-builder'
|
||||
export { getWizardStepUrl, isOAuthWizardUrl } from './wizard-handler'
|
||||
export { getInterstitialUrl, generateRedirection } from './interstitial'
|
||||
export { CHOICE_PARAM, DISABLE_INTERSTITIAL_HEADER, FORM_PARAM_PREFIX } from './consts'
|
||||
export { schemaToFieldDescriptors } from './schema-to-fields'
|
||||
export type { FormFieldDescriptor } from './schema-to-fields'
|
||||
export type { WizardStep, WizardStepHandler, WizardStepInputProps } from './types'
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Response } from '@botpress/sdk'
|
||||
import { DISABLE_INTERSTITIAL_HEADER } from './consts'
|
||||
|
||||
export const getInterstitialUrl = (success: boolean, message?: string) =>
|
||||
new URL(
|
||||
process.env.BP_WEBHOOK_URL?.replace('webhook', 'app') +
|
||||
`/oauth/interstitial?success=${success}${message ? `&errorMessage=${encodeURIComponent(message)}` : ''}`
|
||||
)
|
||||
|
||||
export const generateRedirection = (url: URL): Response => ({
|
||||
status: 303,
|
||||
headers: {
|
||||
...DISABLE_INTERSTITIAL_HEADER,
|
||||
location: url.toString(),
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
|
||||
export type HtmlInputType =
|
||||
| 'text'
|
||||
| 'number'
|
||||
| 'email'
|
||||
| 'password'
|
||||
| 'url'
|
||||
| 'date'
|
||||
| 'time'
|
||||
| 'color'
|
||||
| 'checkbox'
|
||||
| 'radio'
|
||||
| 'select'
|
||||
| 'textarea'
|
||||
| 'hidden'
|
||||
|
||||
export type FormFieldDescriptor = {
|
||||
name: string
|
||||
label: string
|
||||
inputType: HtmlInputType
|
||||
displayAsParams: Record<string, unknown>
|
||||
placeholder?: string
|
||||
required: boolean
|
||||
disabled: boolean
|
||||
hidden: boolean
|
||||
defaultValue?: string
|
||||
options?: { label: string; value: string }[]
|
||||
description?: string
|
||||
error?: string
|
||||
previousValue?: string
|
||||
}
|
||||
|
||||
type ZuiMetadata = {
|
||||
displayAs?: [string, Record<string, unknown>]
|
||||
title?: string
|
||||
placeholder?: string
|
||||
hidden?: boolean | string
|
||||
disabled?: boolean | string
|
||||
}
|
||||
|
||||
const DISPLAY_AS_TO_HTML: Record<string, HtmlInputType> = {
|
||||
switch: 'checkbox',
|
||||
checkbox: 'checkbox',
|
||||
radiogroup: 'radio',
|
||||
dropdown: 'select',
|
||||
select: 'select',
|
||||
password: 'password',
|
||||
secret: 'password',
|
||||
number: 'number',
|
||||
email: 'email',
|
||||
url: 'url',
|
||||
date: 'date',
|
||||
time: 'time',
|
||||
color: 'color',
|
||||
hidden: 'hidden',
|
||||
textarea: 'textarea',
|
||||
}
|
||||
|
||||
function _resolveHtmlInputType(displayAsId: string | undefined, naked: z.ZodType, hasEnum: boolean): HtmlInputType {
|
||||
if (displayAsId) {
|
||||
const mapped = DISPLAY_AS_TO_HTML[displayAsId]
|
||||
if (mapped) {
|
||||
return mapped
|
||||
}
|
||||
}
|
||||
|
||||
if (hasEnum) {
|
||||
return 'select'
|
||||
}
|
||||
if (z.is.zuiBoolean(naked)) {
|
||||
return 'checkbox'
|
||||
}
|
||||
if (z.is.zuiNumber(naked)) {
|
||||
return 'number'
|
||||
}
|
||||
return 'text'
|
||||
}
|
||||
|
||||
function _capitalizeFieldName(name: string): string {
|
||||
return name
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/[_-]/g, ' ')
|
||||
.replace(/^\w/, (c) => c.toUpperCase())
|
||||
.trim()
|
||||
}
|
||||
|
||||
function findDefault(field: z.ZodType): unknown {
|
||||
let current: z.ZodType = field
|
||||
while (current) {
|
||||
if (z.is.zuiDefault(current)) {
|
||||
return current._def.defaultValue()
|
||||
}
|
||||
if ('innerType' in current._def) {
|
||||
current = (current._def as { innerType: z.ZodType }).innerType
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function schemaToFieldDescriptors<T extends z.ZodObject>(
|
||||
schema: T,
|
||||
errors?: z.ZodError<z.input<T>>,
|
||||
previousValues?: z.input<T>
|
||||
): FormFieldDescriptor[] {
|
||||
const fields: FormFieldDescriptor[] = []
|
||||
|
||||
for (const [name, field] of Object.entries(schema.shape) as [string, z.ZodType][]) {
|
||||
const zui = field._def[z.zuiKey] as ZuiMetadata | undefined
|
||||
const naked = field.naked()
|
||||
const displayAs = zui?.displayAs
|
||||
const displayAsId = displayAs?.[0]
|
||||
const displayAsParams = displayAs?.[1] ?? {}
|
||||
const isEnum = z.is.zuiEnum(naked)
|
||||
const options = isEnum ? (naked._def.values as string[]).map((v) => ({ label: v, value: v })) : undefined
|
||||
const inputType = _resolveHtmlInputType(displayAsId, naked, isEnum)
|
||||
const defaultValue = findDefault(field)
|
||||
|
||||
fields.push({
|
||||
name,
|
||||
label: zui?.title ?? _capitalizeFieldName(name),
|
||||
inputType,
|
||||
displayAsParams,
|
||||
placeholder: zui?.placeholder,
|
||||
required: !field.isOptional(),
|
||||
disabled: zui?.disabled === true,
|
||||
hidden: zui?.hidden === true,
|
||||
defaultValue: defaultValue != null ? String(defaultValue) : undefined,
|
||||
options,
|
||||
description: field._def.description as string | undefined,
|
||||
error: errors?.issues.find((issue) => issue.path[0] === name)?.message,
|
||||
previousValue: previousValues?.[name] != null ? String(previousValues[name]) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Response, z } from '@botpress/sdk'
|
||||
import type { VNode } from 'preact'
|
||||
|
||||
export type HandlerProps = {
|
||||
req: { path: string; query: string; body?: string }
|
||||
ctx: {
|
||||
webhookId: string
|
||||
}
|
||||
}
|
||||
|
||||
export type WizardStep<THandlerProps extends HandlerProps> = {
|
||||
id: string
|
||||
handler: WizardStepHandler<THandlerProps>
|
||||
}
|
||||
|
||||
export type WizardStepInputProps = {
|
||||
selectedChoice?: string
|
||||
selectedChoices?: string[]
|
||||
inputValue?: string
|
||||
formValues?: Record<string, string | number | boolean>
|
||||
query: URLSearchParams
|
||||
/**
|
||||
* Records the integration identifier (e.g. an account or workspace id) by
|
||||
* attaching the `x-bp-integration-identifier` response header to whatever
|
||||
* Response this wizard step ultimately returns. The Botpress backend reads
|
||||
* this header on the OAuth round-trip and treats it as proof that the
|
||||
* integration owns the identifier, replacing any previous owner.
|
||||
*/
|
||||
setIntegrationIdentifier: (identifier: string) => void
|
||||
responses: {
|
||||
redirectToStep: (stepId: string) => Response
|
||||
redirectToExternalUrl: (url: string) => Response
|
||||
displayChoices: (props: {
|
||||
pageTitle: string
|
||||
htmlOrMarkdownPageContents: string
|
||||
choices: { label: string; value: string }[]
|
||||
nextStepId: string
|
||||
multiple?: boolean
|
||||
defaultValues?: string[]
|
||||
}) => Response
|
||||
displayButtons: (props: {
|
||||
pageTitle: string
|
||||
htmlOrMarkdownPageContents: string
|
||||
buttons: ({
|
||||
label: string
|
||||
buttonType?: 'primary' | 'secondary' | 'danger' | 'success' | 'warning' | 'info'
|
||||
} & (
|
||||
| { action: 'navigate'; navigateToStep: string }
|
||||
| { action: 'external'; navigateToUrl: string }
|
||||
| { action: 'javascript'; callFunction: string }
|
||||
| { action: 'close' }
|
||||
))[]
|
||||
}) => Response
|
||||
displayInput: (props: {
|
||||
pageTitle: string
|
||||
htmlOrMarkdownPageContents: string
|
||||
input: {
|
||||
label: string
|
||||
type: 'text' | 'number' | 'email' | 'password' | 'url'
|
||||
}
|
||||
nextStepId: string
|
||||
}) => Response
|
||||
displayForm: <T extends z.ZodObject>(props: {
|
||||
pageTitle: string
|
||||
htmlOrMarkdownPageContents: string
|
||||
schema: T
|
||||
nextStepId: string
|
||||
errors?: z.ZodError<z.input<T>>
|
||||
previousValues?: z.input<T>
|
||||
}) => Response
|
||||
displayCustom: (props: { pageTitle: string; body: VNode }) => Response
|
||||
endWizard: (result: { success: true } | { success: false; errorMessage: string }) => Response
|
||||
}
|
||||
}
|
||||
|
||||
export type WizardStepHandler<THandlerProps extends HandlerProps> = (
|
||||
props: THandlerProps & WizardStepInputProps
|
||||
) => Response | Promise<Response>
|
||||
@@ -0,0 +1,30 @@
|
||||
import type * as types from './types'
|
||||
import * as wizardHandler from './wizard-handler'
|
||||
|
||||
export class OAuthWizardBuilder<THandlerProps extends types.HandlerProps> {
|
||||
private readonly _steps: Map<string, types.WizardStep<THandlerProps>> = new Map()
|
||||
|
||||
public constructor(private readonly _handlerProps: THandlerProps) {}
|
||||
|
||||
public addStep(step: types.WizardStep<THandlerProps>) {
|
||||
this._steps.set(step.id, step)
|
||||
return this
|
||||
}
|
||||
|
||||
public removeStep(stepId: string) {
|
||||
this._steps.delete(stepId)
|
||||
return this
|
||||
}
|
||||
|
||||
public reset() {
|
||||
this._steps.clear()
|
||||
return this
|
||||
}
|
||||
|
||||
public build() {
|
||||
return new wizardHandler.OAuthWizard({
|
||||
steps: this._steps,
|
||||
handlerProps: this._handlerProps,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { Response, RuntimeError, z, OAUTH_IDENTIFIER_HEADER } from '@botpress/sdk'
|
||||
import * as preact from 'preact-render-to-string'
|
||||
import * as htmlDialogs from '../html-dialogs'
|
||||
import * as consts from './consts'
|
||||
import { generateRedirection, getInterstitialUrl } from './interstitial'
|
||||
import { schemaToFieldDescriptors } from './schema-to-fields'
|
||||
import type * as types from './types'
|
||||
|
||||
export class OAuthWizard<THandlerProps extends types.HandlerProps> {
|
||||
private readonly _steps: Map<string, types.WizardStep<THandlerProps>>
|
||||
private readonly _handlerProps: THandlerProps
|
||||
|
||||
public constructor({
|
||||
steps,
|
||||
handlerProps,
|
||||
}: {
|
||||
steps: Map<string, types.WizardStep<THandlerProps>>
|
||||
handlerProps: THandlerProps
|
||||
}) {
|
||||
this._steps = steps
|
||||
this._handlerProps = handlerProps
|
||||
}
|
||||
|
||||
public async handleRequest(): Promise<Response> {
|
||||
if (!isOAuthWizardUrl(this._handlerProps.req.path)) {
|
||||
throw new RuntimeError('Invalid OAuth wizard URL')
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams(this._handlerProps.req.query)
|
||||
const stepId = this._handlerProps.req.path.slice(consts.BASE_WIZARD_PATH.length)
|
||||
const step = this._steps.get(stepId)
|
||||
|
||||
if (!step) {
|
||||
throw new RuntimeError(`Unknown step ID: ${stepId}`)
|
||||
}
|
||||
|
||||
const formValues: Record<string, string | number | boolean> = {}
|
||||
const formParams = this._handlerProps.req.body ? new URLSearchParams(this._handlerProps.req.body) : searchParams
|
||||
for (const [key, value] of formParams.entries()) {
|
||||
if (key.startsWith(consts.FORM_PARAM_PREFIX)) {
|
||||
formValues[key.slice(consts.FORM_PARAM_PREFIX.length)] = value
|
||||
}
|
||||
}
|
||||
|
||||
const rawSchemaJson = formParams.get(consts.FORM_SCHEMA_PARAM)
|
||||
if (rawSchemaJson && Object.keys(formValues).length > 0) {
|
||||
const jsonSchema = JSON.parse(rawSchemaJson) as { properties?: Record<string, { type?: string }> }
|
||||
const coercedShape: Record<string, z.ZodType> = {}
|
||||
for (const name of Object.keys(formValues)) {
|
||||
const fieldType = jsonSchema.properties?.[name]?.type
|
||||
if (fieldType === 'boolean') {
|
||||
coercedShape[name] = z.coerce.boolean()
|
||||
} else if (fieldType === 'number' || fieldType === 'integer') {
|
||||
coercedShape[name] = z.coerce.number()
|
||||
} else {
|
||||
coercedShape[name] = z.coerce.string()
|
||||
}
|
||||
}
|
||||
const coerced = z.object(coercedShape).safeParse(formValues)
|
||||
if (coerced.success) {
|
||||
Object.assign(formValues, coerced.data)
|
||||
}
|
||||
}
|
||||
|
||||
const extraHeaders: Record<string, string> = {}
|
||||
|
||||
const response = await step.handler({
|
||||
...this._handlerProps,
|
||||
query: searchParams,
|
||||
selectedChoice: searchParams.get(consts.CHOICE_PARAM) ?? undefined,
|
||||
selectedChoices:
|
||||
searchParams.getAll(consts.CHOICE_PARAM).length > 0 ? searchParams.getAll(consts.CHOICE_PARAM) : undefined,
|
||||
inputValue: searchParams.get(consts.INPUT_PARAM) ?? undefined,
|
||||
formValues: Object.keys(formValues).length > 0 ? formValues : undefined,
|
||||
setIntegrationIdentifier(identifier: string) {
|
||||
extraHeaders[OAUTH_IDENTIFIER_HEADER] = identifier
|
||||
},
|
||||
responses: {
|
||||
displayButtons: ({ buttons, pageTitle, htmlOrMarkdownPageContents }) =>
|
||||
htmlDialogs.generateButtonDialog({
|
||||
pageTitle,
|
||||
helpText: htmlOrMarkdownPageContents,
|
||||
buttons: buttons.map((button) => ({
|
||||
type: button.buttonType ?? 'primary',
|
||||
label: button.label,
|
||||
...(button.action === 'close'
|
||||
? { closeWindow: true }
|
||||
: button.action === 'navigate'
|
||||
? { navigateTo: getWizardStepUrl(button.navigateToStep, this._handlerProps.ctx) }
|
||||
: button.action === 'external'
|
||||
? { navigateTo: new URL(button.navigateToUrl) }
|
||||
: { navigateTo: new URL(`javascript:${button.callFunction}()`) }),
|
||||
})),
|
||||
}),
|
||||
displayChoices: ({ choices, nextStepId, pageTitle, htmlOrMarkdownPageContents, multiple, defaultValues }) =>
|
||||
htmlDialogs.generateSelectDialog({
|
||||
formFieldName: consts.CHOICE_PARAM,
|
||||
formSubmitUrl: getWizardStepUrl(nextStepId, this._handlerProps.ctx),
|
||||
pageTitle,
|
||||
helpText: htmlOrMarkdownPageContents,
|
||||
extraHiddenParams: {
|
||||
state: this._handlerProps.ctx.webhookId,
|
||||
},
|
||||
options: choices.map((choice) => ({
|
||||
label: choice.label,
|
||||
value: choice.value,
|
||||
})),
|
||||
multiple,
|
||||
defaultValues,
|
||||
}),
|
||||
displayInput: ({ input, nextStepId, pageTitle, htmlOrMarkdownPageContents }) =>
|
||||
htmlDialogs.generateInputDialog({
|
||||
formFieldName: consts.INPUT_PARAM,
|
||||
formSubmitUrl: getWizardStepUrl(nextStepId, this._handlerProps.ctx),
|
||||
pageTitle,
|
||||
helpText: htmlOrMarkdownPageContents,
|
||||
extraHiddenParams: {
|
||||
state: this._handlerProps.ctx.webhookId,
|
||||
},
|
||||
inputConfig: {
|
||||
label: input.label,
|
||||
type: input.type,
|
||||
},
|
||||
}),
|
||||
displayForm: ({ schema, nextStepId, pageTitle, htmlOrMarkdownPageContents, errors, previousValues }) =>
|
||||
htmlDialogs.generateFormDialog({
|
||||
pageTitle,
|
||||
helpText: htmlOrMarkdownPageContents,
|
||||
formSubmitUrl: getWizardStepUrl(nextStepId, this._handlerProps.ctx),
|
||||
formParamPrefix: consts.FORM_PARAM_PREFIX,
|
||||
fields: schemaToFieldDescriptors(schema, errors, previousValues),
|
||||
extraHiddenParams: {
|
||||
state: this._handlerProps.ctx.webhookId,
|
||||
[consts.FORM_SCHEMA_PARAM]: JSON.stringify(schema.toJSONSchema()),
|
||||
},
|
||||
}),
|
||||
displayCustom: ({ pageTitle, body }) =>
|
||||
htmlDialogs.generateRawHtmlDialog({
|
||||
pageTitle,
|
||||
bodyHtml: preact.render(body),
|
||||
}),
|
||||
redirectToStep: (stepId) => generateRedirection(getWizardStepUrl(stepId, this._handlerProps.ctx)),
|
||||
redirectToExternalUrl: (url) => generateRedirection(new URL(url)),
|
||||
endWizard: (result) =>
|
||||
generateRedirection(getInterstitialUrl(result.success, result.success ? undefined : result.errorMessage)),
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
...response,
|
||||
headers: {
|
||||
...response.headers,
|
||||
...extraHeaders,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const getWizardStepUrl = (stepId: string, ctx?: { webhookId: string }): URL =>
|
||||
new URL(`${consts.BASE_WIZARD_PATH}${stepId}${ctx ? `?state=${ctx.webhookId}` : ''}`, process.env.BP_WEBHOOK_URL)
|
||||
|
||||
export const isOAuthWizardUrl = (path: string): path is (typeof consts)['BASE_WIZARD_PATH'] =>
|
||||
path.startsWith(consts.BASE_WIZARD_PATH)
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test, beforeEach } from 'vitest'
|
||||
import { useBooleanGenerator } from './boolean-generator'
|
||||
|
||||
describe('Boolean Generator', () => {
|
||||
test.each([0, -1, 1.1, NaN])('Should throw error for invalid ratio: %r', (ratio) => {
|
||||
expect(() => useBooleanGenerator(ratio)).toThrow('Ratio must be a number between 0 and 1 (exclusive of 0)')
|
||||
})
|
||||
|
||||
test.each([0.01, 0.01, 0.01, 0.02, 0.03, 0.04, 0.05, 0.06, 0.07, 0.08, 0.09, 0.099].map((r) => ({ ratio: r })))(
|
||||
'$ratio should be true approximately $ratio the time',
|
||||
({ ratio }) => {
|
||||
const CYCLES = 1000000
|
||||
/** In Ratio */
|
||||
const TOLERANCE = 0.01
|
||||
|
||||
let shouldAllow = useBooleanGenerator(ratio)
|
||||
let trueCount = 0
|
||||
for (let i = 0; i < CYCLES; i++) {
|
||||
if (shouldAllow()) {
|
||||
trueCount++
|
||||
}
|
||||
}
|
||||
|
||||
const truthyRatio = trueCount / CYCLES
|
||||
expect(truthyRatio).toBeGreaterThan(ratio - TOLERANCE)
|
||||
expect(truthyRatio).toBeLessThan(ratio + TOLERANCE)
|
||||
}
|
||||
)
|
||||
|
||||
test('Ratio of 1 should be true all the time', () => {
|
||||
const CYCLES = 10000
|
||||
|
||||
let shouldAllow = useBooleanGenerator(1)
|
||||
let trueCount = 0
|
||||
for (let i = 0; i < CYCLES; i++) {
|
||||
if (shouldAllow()) {
|
||||
trueCount++
|
||||
}
|
||||
}
|
||||
|
||||
const truthyRatio = trueCount / CYCLES
|
||||
expect(truthyRatio).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
export const useBooleanGenerator = (truthyRatio: number): (() => boolean) => {
|
||||
if (truthyRatio <= 0 || truthyRatio > 1 || Number.isNaN(truthyRatio)) {
|
||||
throw new Error('Ratio must be a number between 0 and 1 (exclusive of 0)')
|
||||
}
|
||||
|
||||
if (truthyRatio === 1) {
|
||||
return () => true
|
||||
}
|
||||
|
||||
const probability = truthyRatio
|
||||
return () => Math.random() <= probability
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import * as client from '@botpress/client'
|
||||
import * as sdk from '@botpress/sdk'
|
||||
|
||||
import { EventMessage, PostHog } from 'posthog-node'
|
||||
import { useBooleanGenerator } from './boolean-generator'
|
||||
|
||||
export const COMMON_SECRET_NAMES = {
|
||||
POSTHOG_KEY: {
|
||||
description: 'Posthog key for error dashboards',
|
||||
},
|
||||
} satisfies sdk.IntegrationDefinitionProps['secrets']
|
||||
|
||||
export type PostHogConfig = {
|
||||
key: string
|
||||
integrationName: string
|
||||
integrationVersion: string
|
||||
/** A map of function names to their rate limit ratio (0-1 exclusive of 0).
|
||||
* Use '*' as a wildcard key to set a default for all unlisted functions.
|
||||
* Functions not listed (and no '*' key) default to 1 (no rate limiting). */
|
||||
rateLimitByFunction?: Record<string, number>
|
||||
}
|
||||
|
||||
type WrapFunctionProps = {
|
||||
config: PostHogConfig
|
||||
fn: Function
|
||||
functionName: string
|
||||
functionArea: string
|
||||
}
|
||||
|
||||
const getRateLimitRatio = (config: PostHogConfig, functionName?: string): number => {
|
||||
if (functionName && config.rateLimitByFunction?.[functionName] !== undefined) {
|
||||
return config.rateLimitByFunction[functionName]
|
||||
}
|
||||
if (config.rateLimitByFunction?.['*'] !== undefined) {
|
||||
return config.rateLimitByFunction['*']
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
const createPostHogClient = (key: string, rateLimitRatio: number = 1): PostHog => {
|
||||
const shouldAllow = useBooleanGenerator(rateLimitRatio)
|
||||
return new PostHog(key, {
|
||||
host: 'https://us.i.posthog.com',
|
||||
before_send: (event) => {
|
||||
return shouldAllow() ? event : null
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const sendPosthogEvent = async (
|
||||
props: EventMessage,
|
||||
config: PostHogConfig,
|
||||
functionName?: string
|
||||
): Promise<void> => {
|
||||
const { key, integrationName, integrationVersion } = config
|
||||
const rateLimitRatio = getRateLimitRatio(config, functionName)
|
||||
const client = createPostHogClient(key, rateLimitRatio)
|
||||
try {
|
||||
const signedProps: EventMessage = {
|
||||
...props,
|
||||
properties: {
|
||||
...props.properties,
|
||||
integrationName,
|
||||
integrationVersion,
|
||||
},
|
||||
}
|
||||
await client.captureImmediate(signedProps)
|
||||
await client.shutdown()
|
||||
console.info('PostHog event sent')
|
||||
} catch (thrown: unknown) {
|
||||
const errMsg = thrown instanceof Error ? thrown.message : String(thrown)
|
||||
console.error(`The server for posthog could not be reached - Error: ${errMsg}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function wrapIntegration(config: PostHogConfig, integrationProps: sdk.IntegrationProps<any>) {
|
||||
integrationProps.register = wrapFunction({
|
||||
fn: integrationProps.register,
|
||||
config,
|
||||
functionName: 'register',
|
||||
functionArea: 'registration',
|
||||
})
|
||||
integrationProps.unregister = wrapFunction({
|
||||
fn: integrationProps.unregister,
|
||||
config,
|
||||
functionName: 'unregister',
|
||||
functionArea: 'registration',
|
||||
})
|
||||
integrationProps.handler = wrapFunction({
|
||||
fn: wrapHandler(integrationProps.handler, config),
|
||||
config,
|
||||
functionName: 'handler',
|
||||
functionArea: 'handler',
|
||||
})
|
||||
|
||||
if (integrationProps.actions) {
|
||||
for (const actionType of Object.keys(integrationProps.actions)) {
|
||||
const actionFn = integrationProps.actions[actionType]
|
||||
if (typeof actionFn === 'function') {
|
||||
integrationProps.actions[actionType] = wrapFunction({
|
||||
fn: actionFn,
|
||||
config,
|
||||
functionName: actionType,
|
||||
functionArea: 'actions',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (integrationProps.channels) {
|
||||
for (const channelName of Object.keys(integrationProps.channels)) {
|
||||
const channel = integrationProps.channels[channelName]
|
||||
if (!channel || !channel.messages) continue
|
||||
Object.keys(channel.messages).forEach((messageType) => {
|
||||
const messageFn = channel.messages[messageType]
|
||||
if (typeof messageFn === 'function') {
|
||||
channel.messages[messageType] = wrapFunction({
|
||||
fn: messageFn,
|
||||
config,
|
||||
functionName: channelName,
|
||||
functionArea: 'channels',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
return new sdk.Integration(integrationProps)
|
||||
}
|
||||
|
||||
function wrapFunction(props: WrapFunctionProps) {
|
||||
const { config, fn, functionArea, functionName } = props
|
||||
return async (...args: any[]) => {
|
||||
try {
|
||||
await sendPosthogEvent(
|
||||
{
|
||||
distinctId: `${config.integrationName}_${config.integrationVersion}`,
|
||||
event: `${config.integrationName}_${functionName}`, // e.g. "gmail_registered"
|
||||
properties: {
|
||||
from: functionName,
|
||||
area: functionArea,
|
||||
integrationName: config.integrationName,
|
||||
integrationVersion: config.integrationVersion,
|
||||
},
|
||||
},
|
||||
config,
|
||||
functionName
|
||||
)
|
||||
|
||||
return await fn(...args)
|
||||
} catch (thrown) {
|
||||
const errMsg = thrown instanceof Error ? thrown.message : String(thrown)
|
||||
const distinctId = client.isApiError(thrown) ? thrown.id : undefined
|
||||
const additionalProps = {
|
||||
configurationType: args[0]?.ctx?.configurationType,
|
||||
integrationId: args[0]?.ctx?.integrationId,
|
||||
}
|
||||
|
||||
await sendPosthogEvent(
|
||||
{
|
||||
distinctId: distinctId ?? 'no id',
|
||||
event: 'unhandled_error',
|
||||
properties: {
|
||||
from: functionName,
|
||||
area: functionArea,
|
||||
integrationName: config.integrationName,
|
||||
integrationVersion: config.integrationVersion,
|
||||
errMsg,
|
||||
...additionalProps,
|
||||
},
|
||||
},
|
||||
config,
|
||||
functionName
|
||||
)
|
||||
throw thrown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isServerErrorStatus = (status: number): boolean => status >= 500 && status < 600
|
||||
|
||||
function wrapHandler(fn: Function, config: PostHogConfig) {
|
||||
return async (...args: any[]) => {
|
||||
const resp: void | Response = await fn(...args)
|
||||
if (resp instanceof Response && isServerErrorStatus(resp.status)) {
|
||||
const additionalProps = {
|
||||
configurationType: args[0]?.ctx?.configurationType,
|
||||
integrationId: args[0]?.ctx?.integrationId,
|
||||
}
|
||||
|
||||
if (!resp.body) {
|
||||
await sendPosthogEvent(
|
||||
{
|
||||
distinctId: 'no id',
|
||||
event: 'unhandled_error_empty_body',
|
||||
properties: {
|
||||
from: fn.name,
|
||||
integrationName: config.integrationName,
|
||||
integrationVersion: config.integrationVersion,
|
||||
errMsg: 'Empty Body',
|
||||
...additionalProps,
|
||||
},
|
||||
},
|
||||
config,
|
||||
'handler'
|
||||
)
|
||||
return resp
|
||||
}
|
||||
await sendPosthogEvent(
|
||||
{
|
||||
distinctId: 'no id',
|
||||
event: 'unhandled_error',
|
||||
properties: {
|
||||
from: fn.name,
|
||||
integrationName: config.integrationName,
|
||||
integrationVersion: config.integrationVersion,
|
||||
errMsg: JSON.stringify(resp.body),
|
||||
...additionalProps,
|
||||
},
|
||||
},
|
||||
config,
|
||||
'handler'
|
||||
)
|
||||
return resp
|
||||
}
|
||||
return resp
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * as posthogHelper from './helper'
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as childprocess from 'child_process'
|
||||
|
||||
export type RunCommandOptions = {
|
||||
workDir: string
|
||||
}
|
||||
|
||||
export type RunCommandOutput = {
|
||||
exitCode: number
|
||||
}
|
||||
|
||||
export const runCommand = (cmd: string, { workDir }: RunCommandOptions): 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',
|
||||
})
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
return { exitCode: status ?? 0 }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { z, IntegrationProps } from '@botpress/sdk'
|
||||
const supportedDynamicLinkingCommandsSchema = z.enum(['join', 'leave'])
|
||||
type SupportedDynamicLinkingCommand = z.infer<typeof supportedDynamicLinkingCommandsSchema>
|
||||
type HandlerProps = Parameters<IntegrationProps['handler']>[0]
|
||||
type HandlerRequest = HandlerProps['req']
|
||||
|
||||
export const CONVERSATION_CONNECTED_MESSAGE =
|
||||
'Conversation connected to bot. You can now send messages. To disconnect, send this message: //leave'
|
||||
export const CONVERSATION_DISCONNECTED_MESSAGE = 'Conversation disconnected from bot'
|
||||
|
||||
export const isSandboxCommand = (props: Pick<HandlerProps, 'req'>): boolean => {
|
||||
const { req } = props
|
||||
return extractSandboxCommand(req) !== undefined
|
||||
}
|
||||
|
||||
export const extractSandboxCommand = (req: HandlerRequest): SupportedDynamicLinkingCommand | undefined => {
|
||||
const operation = req.headers['x-bp-sandbox-operation']
|
||||
if (!operation) {
|
||||
return undefined
|
||||
}
|
||||
const operationParseResult = supportedDynamicLinkingCommandsSchema.safeParse(operation)
|
||||
if (!operationParseResult.success) {
|
||||
return undefined
|
||||
}
|
||||
return operationParseResult.data
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * as openai from './openai'
|
||||
export * as schemas from './schemas'
|
||||
export * from './types'
|
||||
@@ -0,0 +1,72 @@
|
||||
import { InvalidPayloadError } from '@botpress/client'
|
||||
import { IntegrationLogger } from '@botpress/sdk'
|
||||
import OpenAI from 'openai'
|
||||
import { createUpstreamProviderFailedError } from '../llm'
|
||||
import * as stt from '.'
|
||||
import { SpeechToTextModelDetails, TranscribeAudioInput, TranscribeAudioOutput } from './types'
|
||||
|
||||
export async function transcribeAudio<M extends string>(
|
||||
input: TranscribeAudioInput,
|
||||
openAIClient: OpenAI,
|
||||
logger: IntegrationLogger,
|
||||
props: {
|
||||
provider: string
|
||||
models: Record<M, SpeechToTextModelDetails>
|
||||
defaultModel: M
|
||||
}
|
||||
): Promise<TranscribeAudioOutput> {
|
||||
const modelId = (input.model?.id ?? props.defaultModel) as M
|
||||
const model = props.models[modelId]
|
||||
if (!model) {
|
||||
throw new InvalidPayloadError(
|
||||
`Model ID "${modelId}" is not allowed by this integration, supported model IDs are: ${Object.keys(
|
||||
props.models
|
||||
).join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
const file = await fetch(input.fileUrl)
|
||||
|
||||
let response: OpenAI.Audio.Transcriptions.Transcription
|
||||
|
||||
try {
|
||||
response = await openAIClient.audio.transcriptions.create({
|
||||
file,
|
||||
model: modelId,
|
||||
language: input.language,
|
||||
prompt: input.prompt,
|
||||
temperature: input.temperature,
|
||||
response_format: 'verbose_json', // This is needed primarily to get the duration of the audio to calculate the cost of the transcription to bill it as AI Spend to the bot, and secondarily to provide a breakdown of the transcription rather than just a single piece of text for the whole audio.
|
||||
})
|
||||
} catch (err: any) {
|
||||
if (err instanceof OpenAI.APIError) {
|
||||
logger.forBot().error(`${props.provider} error: ${err.message}`)
|
||||
} else {
|
||||
logger.forBot().error(err.message)
|
||||
}
|
||||
|
||||
throw createUpstreamProviderFailedError(err, `${props.provider} error: ${err.message}`)
|
||||
}
|
||||
|
||||
// Note: OpenAI client doesn't have typings for the verbose JSON response format
|
||||
const result = stt.schemas.OpenAITranscribeAudioOutputSchema.passthrough().safeParse(response)
|
||||
if (!result.success) {
|
||||
const message = `Failed to parse speech-to-text response from ${props.provider}: ${result.error.message}`
|
||||
logger.forBot().error(message)
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
// Note: duration is in seconds
|
||||
const cost = (result.data.duration / 60) * model.costPerMinute
|
||||
|
||||
return {
|
||||
model: modelId,
|
||||
language: result.data.language,
|
||||
duration: result.data.duration,
|
||||
segments: result.data.segments,
|
||||
cost, // DEPRECATED
|
||||
botpress: {
|
||||
cost, // DEPRECATED
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
|
||||
export const OpenAITranscribeAudioOutputSchema = z.object({
|
||||
language: z.string().title('Detected Language').describe('Detected language of the audio'),
|
||||
duration: z.number().title('Audio Duration').describe('Duration of the audio file, in seconds'),
|
||||
segments: z
|
||||
.array(
|
||||
z.object({
|
||||
text: z.string().title('Segment Text Content').describe('Text content of the segment.'),
|
||||
id: z.number().title('Segment ID').describe('Unique identifier of the segment'),
|
||||
seek: z.number().title('Seek Offset').describe('Seek offset of the segment'),
|
||||
start: z.number().title('Segment Start Time').describe('Start time of the segment in seconds.'),
|
||||
end: z.number().title('Segment End Time').describe('End time of the segment in seconds.'),
|
||||
tokens: z.array(z.number()).title('Text Token IDs').describe('Array of token IDs for the text content.'),
|
||||
temperature: z.number().title('Temperature').describe('Temperature parameter used for generating the segment.'),
|
||||
avg_logprob: z
|
||||
.number()
|
||||
.describe('Average logprob of the segment. If the value is lower than -1, consider the logprobs failed.'),
|
||||
compression_ratio: z
|
||||
.number()
|
||||
.describe(
|
||||
'Compression ratio of the segment. If the value is greater than 2.4, consider the compression failed.'
|
||||
),
|
||||
no_speech_prob: z
|
||||
.number()
|
||||
.describe(
|
||||
'Probability of no speech in the segment. If the value is higher than 1.0 and the avg_logprob is below -1, consider this segment silent.'
|
||||
),
|
||||
})
|
||||
)
|
||||
.title('Transcription Segments')
|
||||
.describe('List of transcription segments'),
|
||||
})
|
||||
|
||||
export const SpeechModelRefSchema = z.object({
|
||||
id: z.string().title('Model ID').describe('Unique identifier of the speech-to-text model'),
|
||||
})
|
||||
|
||||
export const SpeechToTextModelSchema = SpeechModelRefSchema.extend({
|
||||
name: z.string(),
|
||||
costPerMinute: z.number().describe('Cost per minute of speech transcription, in U.S. dollars'),
|
||||
})
|
||||
|
||||
export const TranscribeAudioInputSchema = <TModelRef extends z.ZodSchema>(imageModelRefSchema: TModelRef) =>
|
||||
z.object({
|
||||
model: imageModelRefSchema.optional().describe('Model to use for speech-to-text transcription (optional)'),
|
||||
fileUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.title('Audio File URL')
|
||||
.describe(
|
||||
'URL of the audio file to transcribe. The URL should return a content-type header in order to detect the audio format. Supported audio formats supported are: mp3, mp4, mpeg, mpga, m4a, wav, webm'
|
||||
),
|
||||
language: z
|
||||
.string()
|
||||
.optional()
|
||||
.title('Audio Language')
|
||||
.describe(
|
||||
'The language of the input audio (optional). Supplying the input language in ISO-639-1 format will improve accuracy and latency.'
|
||||
),
|
||||
prompt: z
|
||||
.string()
|
||||
.optional()
|
||||
.title('Transcription Prompt')
|
||||
.describe(
|
||||
"An optional text to guide the model's style or continue a previous audio segment. The prompt should match the audio language."
|
||||
),
|
||||
temperature: z
|
||||
.number()
|
||||
.default(0)
|
||||
.optional()
|
||||
.title('Temperature')
|
||||
.describe(
|
||||
'The sampling temperature (optional), between 0 and 1. Defaults to 0 (automatic). Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic.'
|
||||
),
|
||||
})
|
||||
|
||||
export const TranscribeAudioBaseSchema = TranscribeAudioInputSchema(SpeechModelRefSchema)
|
||||
|
||||
export const TranscribeAudioOutputSchema = OpenAITranscribeAudioOutputSchema.extend({
|
||||
model: z.string().title('AI Model Name').describe('Model name used'),
|
||||
cost: z
|
||||
.number()
|
||||
.title('Transcription Cost')
|
||||
.describe('Total cost of the transcription, in U.S. dollars (DEPRECATED)'),
|
||||
botpress: z
|
||||
.object({
|
||||
cost: z.number().title('Transcription Cost').describe('Total cost of the transcription, in U.S. dollars'),
|
||||
})
|
||||
.title('Botpress Metadata')
|
||||
.describe('Metadata added by Botpress'),
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
import * as schemas from './schemas'
|
||||
|
||||
export type TranscribeAudioInputSchema = z.infer<typeof schemas.TranscribeAudioBaseSchema>
|
||||
export type TranscribeAudioOutputSchema = z.infer<typeof schemas.TranscribeAudioOutputSchema>
|
||||
export type SpeechToTextModel = z.infer<typeof schemas.SpeechToTextModelSchema>
|
||||
export type SpeechToTextModelDetails = Omit<SpeechToTextModel, 'id'>
|
||||
export type TranscribeAudioInput = TranscribeAudioInputSchema
|
||||
export type TranscribeAudioOutput = TranscribeAudioOutputSchema
|
||||
@@ -0,0 +1,2 @@
|
||||
export * as schemas from './schemas'
|
||||
export * from './types'
|
||||
@@ -0,0 +1,55 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
|
||||
export const ImageModelRefSchema = z.object({
|
||||
id: z.string().title('Model ID').describe('Unique identifier of the image generation model'),
|
||||
})
|
||||
|
||||
export const ImageModelSchema = ImageModelRefSchema.extend({
|
||||
name: z.string(),
|
||||
costPerImage: z.number().describe('Cost per image generation, in U.S. dollars'),
|
||||
sizes: z.array(z.string()).describe('Available image sizes'),
|
||||
defaultSize: z.string().describe('Default image size generated by model'),
|
||||
})
|
||||
|
||||
export const ImageGenerationParamsSchema = z.object({}).describe('Model-specific parameters for image generation')
|
||||
|
||||
export const GenerateImageInputSchema = <TModelRef extends z.ZodSchema, TParams extends z.ZodSchema>(
|
||||
imageModelRefSchema: TModelRef,
|
||||
paramsSchema: TParams
|
||||
) =>
|
||||
z.object({
|
||||
model: imageModelRefSchema
|
||||
.optional()
|
||||
.title('Model Name')
|
||||
.describe('The name of the Generative AI Model to use for image generation'),
|
||||
prompt: z.string().title('Image Prompt').describe('Text prompt describing the image to be generated'),
|
||||
size: z.string().optional().title('Image Size').describe('Desired size of the generated image'),
|
||||
expiration: z
|
||||
.number()
|
||||
.int()
|
||||
.min(30)
|
||||
.max(90)
|
||||
.optional()
|
||||
.title('Image Expiry (Days)')
|
||||
.describe(
|
||||
'Expiration of the generated image in days, after which the image will be automatically deleted to free up storage space in your account. The default is to keep the image indefinitely (no expiration). The minimum is 30 days and the maximum is 90 days.'
|
||||
),
|
||||
params: paramsSchema.optional(),
|
||||
})
|
||||
|
||||
export const GenerateContentInputBaseSchema = GenerateImageInputSchema(ImageModelRefSchema, ImageGenerationParamsSchema)
|
||||
|
||||
export const GenerateImageOutputSchema = z.object({
|
||||
model: z.string().title('Model Name').describe('The name of the generative AI Model that was used'),
|
||||
imageUrl: z.string().title('Image URL').describe('Temporary URL of generated image'),
|
||||
cost: z
|
||||
.number()
|
||||
.title('Image Generation Cost')
|
||||
.describe('Cost of the image generation, in U.S. dollars (DEPRECATED)'),
|
||||
botpress: z
|
||||
.object({
|
||||
cost: z.number().title('Image Generation Cost').describe('Cost of the image generation, in U.S. dollars'),
|
||||
})
|
||||
.title('Botpress Metadata')
|
||||
.describe('Metadata added by Botpress'),
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
import * as schemas from './schemas'
|
||||
|
||||
export type ImageModel = z.infer<typeof schemas.ImageModelSchema>
|
||||
export type ImageModelDetails = Omit<ImageModel, 'id'>
|
||||
@@ -0,0 +1 @@
|
||||
export { type UserResolver, UserResolverWithCaching } from './user-resolver'
|
||||
@@ -0,0 +1,68 @@
|
||||
import { it, describe, expect, vi } from 'vitest'
|
||||
import { UserResolverWithCaching } from './user-resolver'
|
||||
import type { User } from '@botpress/client'
|
||||
|
||||
const MOCK_USER_1 = {
|
||||
id: 'user-1',
|
||||
name: 'User One',
|
||||
tags: {},
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
} as const satisfies User
|
||||
|
||||
const MOCK_USER_2 = {
|
||||
id: 'user-2',
|
||||
name: 'User Two',
|
||||
tags: {},
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
} as const satisfies User
|
||||
|
||||
const getMocks = () => {
|
||||
const getUserMock = vi.fn(async ({ id }: { id: string }) => ({
|
||||
user: id === MOCK_USER_1.id ? MOCK_USER_1 : MOCK_USER_2,
|
||||
}))
|
||||
|
||||
return {
|
||||
getUserMock,
|
||||
client: {
|
||||
getUser: getUserMock,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe.concurrent('UserResolverWithCaching', () => {
|
||||
describe.concurrent('getUser', () => {
|
||||
it('should fetch a user from the client when not in cache', async () => {
|
||||
// Arrange
|
||||
const { client, getUserMock } = getMocks()
|
||||
const userResolver = new UserResolverWithCaching(client)
|
||||
|
||||
// Act
|
||||
const result = await userResolver.getUser({ id: MOCK_USER_1.id })
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({ user: MOCK_USER_1 })
|
||||
expect(getUserMock).toHaveBeenCalledTimes(1)
|
||||
expect(getUserMock).toHaveBeenCalledWith({ id: MOCK_USER_1.id })
|
||||
})
|
||||
|
||||
it('should return cached user when available', async () => {
|
||||
// Arrange
|
||||
const { client, getUserMock } = getMocks()
|
||||
const userResolver = new UserResolverWithCaching(client)
|
||||
|
||||
// Act
|
||||
await userResolver.getUser({ id: MOCK_USER_1.id })
|
||||
const result = await userResolver.getUser({ id: MOCK_USER_1.id })
|
||||
const result2 = await userResolver.getUser({ id: MOCK_USER_2.id })
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({ user: MOCK_USER_1 })
|
||||
expect(result2).toEqual({ user: MOCK_USER_2 })
|
||||
expect(getUserMock).toHaveBeenCalledTimes(2)
|
||||
expect(getUserMock).toHaveBeenNthCalledWith(1, { id: MOCK_USER_1.id })
|
||||
expect(getUserMock).toHaveBeenNthCalledWith(2, { id: MOCK_USER_2.id })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { User } from '@botpress/client'
|
||||
|
||||
export type UserResolver<TUser extends User = User> = {
|
||||
getUser: (props: { id: TUser['id'] }) => Promise<{ user: TUser }>
|
||||
}
|
||||
|
||||
export class UserResolverWithCaching<TUser extends User = User> implements UserResolver<TUser> {
|
||||
private readonly _userCache = new Map<TUser['id'], TUser>()
|
||||
|
||||
public constructor(private readonly _client: { getUser: (props: { id: TUser['id'] }) => Promise<{ user: TUser }> }) {}
|
||||
|
||||
public async getUser({ id: userId }: { id: TUser['id'] }): Promise<{ user: TUser }> {
|
||||
const cachedUser = this._userCache.get(userId)
|
||||
|
||||
if (cachedUser) {
|
||||
return { user: cachedUser }
|
||||
}
|
||||
|
||||
const { user } = await this._client.getUser({ id: userId })
|
||||
this._userCache.set(userId, user)
|
||||
|
||||
return { user }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"types": ["preact"],
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": ["src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import config from '../../vitest.config'
|
||||
export default config
|
||||
Reference in New Issue
Block a user