diff --git a/integrations/vonage/integration.definition.ts b/integrations/vonage/integration.definition.ts index c79b37f435c..acd33fcace1 100644 --- a/integrations/vonage/integration.definition.ts +++ b/integrations/vonage/integration.definition.ts @@ -1,10 +1,12 @@ /* bplint-disable */ import { z, IntegrationDefinition, messages } from '@botpress/sdk' import { sentry as sentryHelpers } from '@botpress/sdk-addons' +import proactiveConversation from 'bp_modules/proactive-conversation' +import proactiveUser from 'bp_modules/proactive-user' export default new IntegrationDefinition({ name: 'vonage', - version: '0.4.6', + version: '1.0.0', title: 'Vonage', description: 'Send and receive SMS messages.', icon: 'icon.svg', @@ -19,7 +21,7 @@ export default new IntegrationDefinition({ }, channels: { channel: { - messages: { ...messages.defaults, markdown: messages.markdown }, + messages: { ...messages.defaults }, message: { tags: { id: {}, @@ -31,18 +33,49 @@ export default new IntegrationDefinition({ channel: {}, channelId: {}, }, - creation: { enabled: true, requiredTags: ['userId', 'channelId', 'channel'] }, }, }, }, - actions: {}, events: {}, user: { tags: { userId: {}, channel: {}, }, - creation: { enabled: true, requiredTags: ['userId', 'channel'] }, }, secrets: sentryHelpers.COMMON_SECRET_NAMES, + entities: { + conversation: { + schema: z.object({ + userId: z.string(), + channelId: z.string(), + channel: z.string(), + }), + }, + user: { + schema: z.object({ channel: z.string(), userId: z.string() }), + }, + }, }) + .extend(proactiveConversation, ({ entities }) => ({ + entities: { + conversation: entities.conversation, + }, + actions: { + getOrCreateConversation: { + name: 'startConversation', + title: 'Start proactive conversation', + description: 'Start a proactive conversation given a user', + }, + }, + })) + .extend(proactiveUser, ({ entities }) => ({ + entities: { user: entities.user }, + actions: { + getOrCreateUser: { + name: 'getOrCreateUser', + title: 'Get or create user', + description: 'Get or create a user in the Vonage channel', + }, + }, + })) diff --git a/integrations/vonage/package.json b/integrations/vonage/package.json index fac0edba7d1..abf995ed146 100644 --- a/integrations/vonage/package.json +++ b/integrations/vonage/package.json @@ -17,5 +17,9 @@ "@botpress/cli": "workspace:*", "@botpress/common": "workspace:*", "@sentry/cli": "^2.39.1" + }, + "bpDependencies": { + "proactive-user": "../../interfaces/proactive-user", + "proactive-conversation": "../../interfaces/proactive-conversation" } } diff --git a/integrations/vonage/src/index.ts b/integrations/vonage/src/index.ts index 12910fcd846..8df4a12f10c 100644 --- a/integrations/vonage/src/index.ts +++ b/integrations/vonage/src/index.ts @@ -1,60 +1,99 @@ import { RuntimeError } from '@botpress/client' -import { sentry as sentryHelpers } from '@botpress/sdk-addons' -import axios from 'axios' +import * as sdk from '@botpress/sdk' +import * as common from '@botpress/sdk-addons' +import * as formatter from './payloadFormatter' +import * as vonage from './vonage' import * as bp from '.botpress' const integration = new bp.Integration({ register: async () => {}, unregister: async () => {}, - actions: {}, + actions: { + async startConversation(props) { + const vonageChannel = props.input.conversation.channel + const channelId = props.input.conversation.channelId + const userId = props.input.conversation.userId + + if (!(vonageChannel && channelId && userId)) { + throw new sdk.RuntimeError('Could not create conversation: missing channel, channelId or userId') + } + + const { conversation } = await props.client.getOrCreateConversation({ + tags: { + channel: vonageChannel, + channelId, + userId, + }, + channel: 'channel', + }) + + return { + conversationId: conversation.id, + } + }, + async getOrCreateUser(props) { + const vonageChannel = props.input.user.channel + const userId = props.input.user.userId + if (!(vonageChannel && userId)) { + throw new sdk.RuntimeError('Could not create a user: missing channel or userId') + } + + const { user } = await props.client.getOrCreateUser({ + tags: { + channel: vonageChannel, + userId, + }, + }) + + return { + userId: user.id, + } + }, + }, channels: { channel: { messages: { text: async (props) => { const payload = { message_type: 'text', text: props.payload.text } - await sendMessage(props, payload) + await vonage.sendMessage(props, payload) }, image: async (props) => { const payload = { message_type: 'image', image: { url: props.payload.imageUrl } } - await sendMessage(props, payload) - }, - markdown: async (props) => { - const payload = { message_type: 'text', text: props.payload.markdown } - await sendMessage(props, payload) + await vonage.sendMessage(props, payload) }, audio: async (props) => { const payload = { message_type: 'audio', audio: { url: props.payload.audioUrl } } - await sendMessage(props, payload) + await vonage.sendMessage(props, payload) }, video: async (props) => { const payload = { message_type: 'video', video: { url: props.payload.videoUrl } } - await sendMessage(props, payload) + await vonage.sendMessage(props, payload) }, file: async (props) => { const payload = { message_type: 'file', file: { url: props.payload.fileUrl } } - await sendMessage(props, payload) + await vonage.sendMessage(props, payload) }, location: async (props) => { - const payload = formatLocationPayload(props.payload) - await sendMessage(props, payload) + const payload = formatter.formatLocationPayload(props.payload) + await vonage.sendMessage(props, payload) }, carousel: async (props) => { - const payloads = formatCarouselPayload(props.payload) + const payloads = formatter.formatCarouselPayload(props.payload) for (const payload of payloads) { - await sendMessage(props, payload) + await vonage.sendMessage(props, payload) } }, card: async (props) => { - const payload = formatCardPayload(props.payload) - await sendMessage(props, payload) + const payload = formatter.formatCardPayload(props.payload) + await vonage.sendMessage(props, payload) }, dropdown: async (props) => { - const payload = formatDropdownPayload(props.payload) - await sendMessage(props, payload) + const payload = formatter.formatDropdownPayload(props.payload) + await vonage.sendMessage(props, payload) }, choice: async (props) => { - const payload = formatChoicePayload(props.payload) - await sendMessage(props, payload) + const payload = formatter.formatChoicePayload(props.payload) + await vonage.sendMessage(props, payload) }, bloc: () => { throw new RuntimeError('Not implemented') @@ -76,10 +115,6 @@ const integration = new bp.Integration({ throw new Error('Handler received an invalid message type') } - if (data.channel !== 'whatsapp') { - throw new Error('Handler received an invalid channel') - } - const { conversation } = await client.getOrCreateConversation({ channel: 'channel', tags: { @@ -104,230 +139,10 @@ const integration = new bp.Integration({ payload: { text: data.text }, }) }, - createUser: async ({ client, tags }) => { - const vonageChannel = tags.channel - const userId = tags.userId - if (!(vonageChannel && userId)) { - return - } - - const { user } = await client.getOrCreateUser({ - tags: { - channel: vonageChannel, - userId, - }, - }) - - return { - body: JSON.stringify({ user: { id: user.id } }), - headers: {}, - statusCode: 200, - } - }, - createConversation: async ({ client, channel, tags }) => { - const vonageChannel = tags.channel - const channelId = tags.channelId - const userId = tags.userId - - if (!(vonageChannel && channelId && userId)) { - return - } - - const { conversation } = await client.getOrCreateConversation({ - channel, - tags: { - channel: vonageChannel, - channelId, - userId, - }, - }) - - return { - body: JSON.stringify({ conversation: { id: conversation.id } }), - headers: {}, - statusCode: 200, - } - }, }) -export default sentryHelpers.wrapIntegration(integration, { +export default common.sentry.wrapIntegration(integration, { dsn: bp.secrets.SENTRY_DSN, environment: bp.secrets.SENTRY_ENVIRONMENT, release: bp.secrets.SENTRY_RELEASE, }) - -function getRequestMetadata(conversation: SendMessageProps['conversation']) { - const channel = conversation.tags?.channel - const channelId = conversation.tags?.channelId - const userId = conversation.tags?.userId - - if (!channelId) { - throw new Error('Invalid channel id') - } - - if (!userId) { - throw new Error('Invalid user id') - } - - if (!channel) { - throw new Error('Invalid channel') - } - - return { to: userId, from: channelId, channel } -} - -type Dropdown = bp.channels.channel.dropdown.Dropdown -type Choice = bp.channels.channel.choice.Choice -type Carousel = bp.channels.channel.carousel.Carousel -type Card = bp.channels.channel.card.Card -type Location = bp.channels.channel.location.Location - -function formatLocationPayload(payload: Location) { - return { - message_type: 'custom', - custom: { - type: 'location', - location: { - latitude: payload.latitude, - longitude: payload.longitude, - }, - }, - } -} - -function formatDropdownPayload(payload: Dropdown) { - return { - message_type: 'custom', - custom: { - type: 'interactive', - interactive: { - type: 'list', - body: { - text: payload.text, - }, - action: { - button: 'Select an option', - sections: [ - { - rows: payload.options.map((x, i) => ({ id: `slot-${i}::${x.value}`, title: x.label })), - }, - ], - }, - }, - }, - } -} - -function formatChoicePayload(payload: Choice) { - if (payload.options.length < 3) { - return { - message_type: 'custom', - custom: { - type: 'interactive', - interactive: { - type: 'button', - body: { - text: payload.text, - }, - action: { - buttons: payload.options.map((x, i) => ({ - type: 'reply', - reply: { id: `slot-${i}::${x.value}`, title: x.label }, - })), - }, - }, - }, - } - } - - if (payload.options.length <= 10) { - return { - message_type: 'custom', - custom: { - type: 'interactive', - interactive: { - type: 'list', - body: { - text: payload.text, - }, - action: { - button: 'Select an option', - sections: [ - { - rows: payload.options.map((x, i) => ({ id: `slot-${i}::${x.value}`, title: x.label })), - }, - ], - }, - }, - }, - } - } - - return { - message_type: 'text', - text: `${payload.text}\n\n${payload.options.map(({ label }, idx) => `*(${idx + 1})* ${label}`).join('\n')}`, - } -} - -function formatCarouselPayload(payload: Carousel) { - let count = 0 - return payload.items.map((card) => { - const cardPayload = formatCardPayload(card, count) - count += card.actions.length - return cardPayload - }) -} - -type CardOption = CardSay | CardPostback | CardUrl - -type CardSay = { title: string; type: 'say'; value: string } -type CardPostback = { title: string; type: 'postback'; value: string } -type CardUrl = { title: string; type: 'url' } - -function formatCardPayload(payload: Card, count: number = 0) { - const options: CardOption[] = [] - - payload.actions.forEach((action) => { - if (action.action === 'say') { - options.push({ title: action.label, type: 'say', value: action.value }) - } else if (action.action === 'url') { - options.push({ title: `${action.label} : ${action.value}`, type: 'url' }) - } else if (action.action === 'postback') { - options.push({ title: action.label, type: 'postback', value: action.value }) - } - }) - - const body = `*${payload.title}*\n\n${payload.subtitle ? `${payload.subtitle}\n\n` : ''}${options - .map(({ title }, idx) => `*(${idx + count + 1})* ${title}`) - .join('\n')}` - - if (payload.imageUrl) { - return { - message_type: 'image', - image: { - url: payload.imageUrl, - caption: body, - }, - } - } - - return { message_type: 'text', text: body } -} -type SendMessageProps = Pick -async function sendMessage({ conversation, ctx, ack }: SendMessageProps, payload: any) { - const { to, from, channel } = getRequestMetadata(conversation) - const response = await axios.post( - 'https://api.nexmo.com/v1/messages', - { - ...payload, - from, - to, - channel, - }, - { - headers: { 'Content-Type': 'application/json' }, - auth: { username: ctx.configuration.apiKey, password: ctx.configuration.apiSecret }, - } - ) - await ack({ tags: { id: response.data.message_uuid } }) -} diff --git a/integrations/vonage/src/payloadFormatter.ts b/integrations/vonage/src/payloadFormatter.ts new file mode 100644 index 00000000000..81645fa58e8 --- /dev/null +++ b/integrations/vonage/src/payloadFormatter.ts @@ -0,0 +1,133 @@ +import * as bp from '.botpress' + +export function formatLocationPayload(payload: bp.channels.channel.location.Location) { + return { + message_type: 'custom', + custom: { + type: 'location', + location: { + latitude: payload.latitude, + longitude: payload.longitude, + }, + }, + } +} + +export function formatDropdownPayload(payload: bp.channels.channel.dropdown.Dropdown) { + return { + message_type: 'custom', + custom: { + type: 'interactive', + interactive: { + type: 'list', + body: { + text: payload.text, + }, + action: { + button: 'Select an option', + sections: [ + { + rows: payload.options.map((x, i) => ({ id: `slot-${i}::${x.value}`, title: x.label })), + }, + ], + }, + }, + }, + } +} + +export function formatChoicePayload(payload: bp.channels.channel.choice.Choice) { + if (payload.options.length < 3) { + return { + message_type: 'custom', + custom: { + type: 'interactive', + interactive: { + type: 'button', + body: { + text: payload.text, + }, + action: { + buttons: payload.options.map((x, i) => ({ + type: 'reply', + reply: { id: `slot-${i}::${x.value}`, title: x.label }, + })), + }, + }, + }, + } + } + + if (payload.options.length <= 10) { + return { + message_type: 'custom', + custom: { + type: 'interactive', + interactive: { + type: 'list', + body: { + text: payload.text, + }, + action: { + button: 'Select an option', + sections: [ + { + rows: payload.options.map((x, i) => ({ id: `slot-${i}::${x.value}`, title: x.label })), + }, + ], + }, + }, + }, + } + } + + return { + message_type: 'text', + text: `${payload.text}\n\n${payload.options.map(({ label }, idx) => `*(${idx + 1})* ${label}`).join('\n')}`, + } +} + +export function formatCarouselPayload(payload: bp.channels.channel.carousel.Carousel) { + let count = 0 + return payload.items.map((card) => { + const cardPayload = formatCardPayload(card, count) + count += card.actions.length + return cardPayload + }) +} + +type CardOption = CardSay | CardPostback | CardUrl + +type CardSay = { title: string; type: 'say'; value: string } +type CardPostback = { title: string; type: 'postback'; value: string } +type CardUrl = { title: string; type: 'url' } + +export function formatCardPayload(payload: bp.channels.channel.card.Card, count: number = 0) { + const options: CardOption[] = [] + + payload.actions.forEach((action) => { + if (action.action === 'say') { + options.push({ title: action.label, type: 'say', value: action.value }) + } else if (action.action === 'url') { + options.push({ title: `${action.label} : ${action.value}`, type: 'url' }) + } else if (action.action === 'postback') { + options.push({ title: action.label, type: 'postback', value: action.value }) + } + }) + + const body = `*${payload.title}*\n\n${payload.subtitle ? `${payload.subtitle}\n\n` : ''}${options + .map(({ title }, idx) => `*(${idx + count + 1})* ${title}`) + .join('\n')}` + + if (payload.imageUrl) { + return { + message_type: 'image', + image: { + url: payload.imageUrl, + caption: body, + }, + } + } + + return { message_type: 'text', text: body } +} diff --git a/integrations/vonage/src/vonage.ts b/integrations/vonage/src/vonage.ts new file mode 100644 index 00000000000..8d94e56d59e --- /dev/null +++ b/integrations/vonage/src/vonage.ts @@ -0,0 +1,41 @@ +import axios from 'axios' +import * as bp from '.botpress' + +function getRequestMetadata(conversation: SendMessageProps['conversation']) { + const channel = conversation.tags?.channel + const channelId = conversation.tags?.channelId + const userId = conversation.tags?.userId + + if (!channelId) { + throw new Error('Invalid channel id') + } + + if (!userId) { + throw new Error('Invalid user id') + } + + if (!channel) { + throw new Error('Invalid channel') + } + + return { to: userId, from: channelId, channel } +} + +type SendMessageProps = Pick +export async function sendMessage({ conversation, ctx, ack }: SendMessageProps, payload: any) { + const { to, from, channel } = getRequestMetadata(conversation) + const response = await axios.post( + 'https://api.nexmo.com/v1/messages', + { + ...payload, + from, + to, + channel, + }, + { + headers: { 'Content-Type': 'application/json' }, + auth: { username: ctx.configuration.apiKey, password: ctx.configuration.apiSecret }, + } + ) + await ack({ tags: { id: response.data.message_uuid } }) +} diff --git a/packages/llmz/package.json b/packages/llmz/package.json index 354ed1f5470..e211f68282f 100644 --- a/packages/llmz/package.json +++ b/packages/llmz/package.json @@ -2,8 +2,10 @@ "name": "llmz", "type": "module", "description": "LLMz – An LLM-native Typescript VM built on top of Zui", - "version": "0.0.16", + "version": "0.0.17", "types": "./dist/index.d.ts", + "main": "./dist/index.cjs", + "module": "./dist/index.js", "exports": { ".": { "import": "./dist/index.js", diff --git a/packages/llmz/src/index.ts b/packages/llmz/src/index.ts index 29ba489b687..a342e213cd9 100644 --- a/packages/llmz/src/index.ts +++ b/packages/llmz/src/index.ts @@ -23,7 +23,7 @@ export { Chat, type MessageHandler } from './chat.js' import { type ExecutionProps } from './llmz.js' import { ExecutionResult } from './result.js' -import { wrapContent } from './truncator.js' +import { truncateWrappedContent, wrapContent } from './truncator.js' import { toValidFunctionName, toValidObjectName } from './utils.js' export { Transcript } from './transcript.js' export { ErrorExecutionResult, ExecutionResult, PartialExecutionResult, SuccessExecutionResult } from './result.js' @@ -37,6 +37,7 @@ export const utils = { toValidObjectName, toValidFunctionName, wrapContent, + truncateWrappedContent, } /** diff --git a/packages/llmz/src/truncator.ts b/packages/llmz/src/truncator.ts index 418999f4269..3aa904e3f55 100644 --- a/packages/llmz/src/truncator.ts +++ b/packages/llmz/src/truncator.ts @@ -33,6 +33,44 @@ const DEFAULT_TRUNCATE_OPTIONS: TruncateOptions = { minTokens: 0, } +/** + * Wraps content with truncation tags to mark it as truncatable when using `truncateWrappedContent`. + * + * This function encases the provided content within special truncation tags that contain metadata + * about how the content should be truncated. The wrapped content becomes eligible for intelligent + * truncation while preserving non-wrapped content intact. + * + * @param content - The string content to wrap with truncation tags + * @param options - Optional truncation configuration + * @param options.preserve - Which part of the content to preserve when truncating: + * - 'top': Keep the beginning, remove from the end (default) + * - 'bottom': Keep the end, remove from the beginning + * - 'both': Keep both ends, remove from the middle + * @param options.flex - Priority factor for truncation (default: 1). Higher values make this + * content more likely to be truncated. A flex of 2 means this content can shrink twice as + * much as content with flex of 1. + * @param options.minTokens - Minimum number of tokens to preserve (default: 0). Content will + * never be truncated below this threshold. + * + * @returns The content wrapped with truncation tags and embedded metadata + * + * @example + * ```typescript + * // Basic usage - content will be truncated from the end if needed + * const wrapped = wrapContent("This is some long content that might need truncation") + * + * // Preserve the end of the content + * const bottomPreserved = wrapContent("Error log: ... important error details", { + * preserve: 'bottom' + * }) + * + * // High priority for truncation with minimum preservation + * const flexible = wrapContent("Optional context information", { + * flex: 3, + * minTokens: 50 + * }) + * ``` + */ export function wrapContent(content: string, options?: Partial): string { const preserve: TruncateOptions['preserve'] = options?.preserve ?? DEFAULT_TRUNCATE_OPTIONS.preserve const flex: TruncateOptions['flex'] = options?.flex ?? DEFAULT_TRUNCATE_OPTIONS.flex @@ -40,6 +78,64 @@ export function wrapContent(content: string, options?: Partial) return `${WRAP_OPEN_TAG_1} preserve:${preserve} flex:${flex} min:${minTokens} ${WRAP_OPEN_TAG_2}${content}${WRAP_CLOSE_TAG}` } +/** + * Intelligently truncates message content to fit within a token limit while preserving important parts. + * + * This function processes an array of messages and reduces their total token count to fit within the + * specified limit. It only truncates content that has been wrapped with `wrapContent()`, leaving + * unwrapped content completely intact. The truncation algorithm prioritizes content based on flex + * values and respects preservation preferences and minimum token requirements. + * + * ## How it works: + * 1. **Parsing**: Scans each message for wrapped content sections and unwrapped sections + * 2. **Token counting**: Calculates tokens for each section using the configured tokenizer + * 3. **Prioritization**: Identifies the largest truncatable sections based on flex values + * 4. **Intelligent truncation**: Removes content according to preservation preferences + * 5. **Reconstruction**: Rebuilds messages with truncated content and removes wrapper tags + * + * ## Truncation strategy: + * - **Priority**: Higher flex values = higher truncation priority + * - **Minimum tokens**: Content is never truncated below its `minTokens` threshold + * - **Preservation modes**: + * - `'top'`: Removes from the end, keeps the beginning + * - `'bottom'`: Removes from the beginning, keeps the end + * - `'both'`: Removes from the middle, keeps both ends + * + * @template T - Type extending MessageLike (must have a content property) + * @param options - Configuration object + * @param options.messages - Array of messages to truncate + * @param options.tokenLimit - Maximum total tokens allowed across all messages + * @param options.throwOnFailure - Whether to throw an error if truncation fails (default: true). + * If false, returns the best effort result even if over the token limit. + * + * @returns Array of messages with content truncated to fit the token limit + * + * @throws Error if unable to truncate enough content to meet the token limit (when throwOnFailure is true) + * + * @example + * ```typescript + * const messages = [ + * { + * role: 'system', + * content: 'You are a helpful assistant. ' + wrapContent('Here is some background info...', { flex: 2 }) + * }, + * { + * role: 'user', + * content: 'Please help me with: ' + wrapContent('detailed context and examples', { preserve: 'both' }) + * } + * ] + * + * // Truncate to fit within 1000 tokens + * const truncated = truncateWrappedContent({ + * messages, + * tokenLimit: 1000, + * throwOnFailure: false + * }) + * + * // The system message background info will be truncated first (higher flex), + * // and user context will be truncated from the middle if needed (preserve: 'both') + * ``` + */ export function truncateWrappedContent({ messages, tokenLimit,