From 47caed77e2052c244aea9406ba32dbcb1d3ec088 Mon Sep 17 00:00:00 2001 From: Xavier Hamel Date: Tue, 28 Apr 2026 08:20:33 -0400 Subject: [PATCH 1/5] feat(chat): add websocket support (#14355) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Hubert Bélanger Co-authored-by: Hubert Bélanger Co-authored-by: Hubert Belanger <147661569+HubertBel@users.noreply.github.com> --- echo.Tiltfile | 2 +- integrations/chat/package.json | 3 +- integrations/chat/src/handler.ts | 21 +++++ .../chat/src/signal-emitter/pushpin.ts | 7 ++ integrations/chat/src/websocket.ts | 78 +++++++++++++++++++ packages/chat-client/e2e/message.test.ts | 60 ++++++++------ packages/chat-client/e2e/participant.test.ts | 12 +-- packages/chat-client/package.json | 7 +- packages/chat-client/src/client.ts | 16 +++- packages/chat-client/src/eventsource.ts | 67 ++++++++++------ packages/chat-client/src/index.ts | 1 + packages/chat-client/src/signal-listener.ts | 11 ++- packages/cli/src/chat/index.ts | 6 +- .../command-implementations/chat-command.ts | 2 +- packages/cli/src/config.ts | 53 +++++++++++-- pnpm-lock.yaml | 48 ++++++------ 16 files changed, 298 insertions(+), 96 deletions(-) create mode 100644 integrations/chat/src/websocket.ts diff --git a/echo.Tiltfile b/echo.Tiltfile index 7e2aa29c6dc..2cba24ccb43 100644 --- a/echo.Tiltfile +++ b/echo.Tiltfile @@ -108,7 +108,7 @@ dynamodb_resource = { } } -PUSHPIN_ROUTES = "* %s:443,ssl,insecure,host=%s" % (API.bp_webhook_domain, API.bp_webhook_domain) +PUSHPIN_ROUTES = "*,proto=ws %s:443,ssl,insecure,host=%s,over_http\n* %s:443,ssl,insecure,host=%s" % (API.bp_webhook_domain, API.bp_webhook_domain, API.bp_webhook_domain, API.bp_webhook_domain) PUSHPIN_CONFIG = "%s" % read_file(PUSHPIN_CONFIG_PATH, '') pushpin_ressource = { "image": "botpress/pushpin", diff --git a/integrations/chat/package.json b/integrations/chat/package.json index 312d9311a51..11facbfeb8f 100644 --- a/integrations/chat/package.json +++ b/integrations/chat/package.json @@ -1,5 +1,6 @@ { "name": "@botpresshub/chat", + "private": true, "scripts": { "check:type": "tsc --noEmit", "check:bplint": "bp lint", @@ -7,10 +8,10 @@ "build": "bp add -y && bp build", "test": "vitest --run" }, - "private": true, "dependencies": { "@aws-sdk/client-dynamodb": "^3.564.0", "@botpress/sdk": "workspace:*", + "@bpinternal/pingrip": "0.1.1", "@opentelemetry/api": "1.9.0", "@opentelemetry/core": "1.30.0", "@opentelemetry/exporter-trace-otlp-http": "0.54.2", diff --git a/integrations/chat/src/handler.ts b/integrations/chat/src/handler.ts index 9f8558f9e24..2d5037c90c7 100644 --- a/integrations/chat/src/handler.ts +++ b/integrations/chat/src/handler.ts @@ -1,9 +1,11 @@ import { Request } from '@botpress/sdk' import * as api from './api' import { extraRoutes } from './extra-routes' +import * as errors from './gen/errors' import { handleRequest, Router } from './gen/handler' import { httpRequestsTotal, httpRequestDuration } from './metrics' import { Handler } from './types' +import * as websocket from './websocket' const isPushpinRequest = (req: Request) => 'grip-sig' in req.headers @@ -30,6 +32,25 @@ export const makeHandler = } } + if (websocket.isWebSocketRequest(args.req) && args.req.body) { + try { + return await websocket.handleWebSocketRequest(props, args.req) + } catch (thrown: unknown) { + if (errors.isApiError(thrown)) { + return { + status: thrown.code, + body: JSON.stringify(thrown.toJSON()), + } + } + return { + status: 500, + body: JSON.stringify({ + message: thrown instanceof Error ? thrown.message : 'Unknown error', + }), + } + } + } + const match = router.match(args.req.path) const normalizedPath = match?.path ?? 'not_found' const method = args.req.method.toLowerCase() diff --git a/integrations/chat/src/signal-emitter/pushpin.ts b/integrations/chat/src/signal-emitter/pushpin.ts index 84d29674b2a..0aa9a9b4934 100644 --- a/integrations/chat/src/signal-emitter/pushpin.ts +++ b/integrations/chat/src/signal-emitter/pushpin.ts @@ -58,6 +58,10 @@ export class PushpinEmitter implements SignalEmitter { action: 'send', content: this._sse(signal), }, + 'ws-message': { + action: 'send', + content: JSON.stringify(signal), + }, }, }, ], @@ -73,6 +77,9 @@ export class PushpinEmitter implements SignalEmitter { 'http-stream': { action: 'close', }, + 'ws-message': { + action: 'close', + }, }, }, ], diff --git a/integrations/chat/src/websocket.ts b/integrations/chat/src/websocket.ts new file mode 100644 index 00000000000..1dfaa080b05 --- /dev/null +++ b/integrations/chat/src/websocket.ts @@ -0,0 +1,78 @@ +import { Request } from '@botpress/sdk' +import { messages, outputs } from '@bpinternal/pingrip' +import qs from 'qs' +import * as api from './api' +import * as errors from './gen/errors' + +const WS_CLOSE_GOING_AWAY = 1001 + +export const isWebSocketRequest = (req: Request) => { + if (req.method.toLowerCase() !== 'post') { + return false + } + const parts = req.path.split('/').splice(1) + if (parts.length !== 3) { + return false + } + return parts[0] === 'conversations' && parts[2] === 'listen' +} + +type RequestIdentifiers = { + conversationId: string + userId: string +} + +const extractRequestIdentifiers = async (props: api.OperationTools, req: Request): Promise => { + const queries = qs.parse(req.query) + if (!queries['x-user-key'] || typeof queries['x-user-key'] !== 'string') { + throw new errors.UnauthorizedError('x-user-key should be specified as a query param.') + } + const userKey = queries['x-user-key'] + + const _convId = req.path.split('/').splice(1)[1] + if (_convId === undefined) { + throw new errors.InternalError('An unexpected error occurred.') + } + const _userId = props.auth.parseKey(userKey).id + const [userId, conversationId] = await Promise.all([ + props.userIdStore.byFid.get(_userId), + props.convIdStore.byFid.get(_convId), + ]) + + return { + userId, + conversationId, + } +} + +export const handleWebSocketRequest = async (props: api.OperationTools, req: Request) => { + if (!req.body) { + throw new errors.InvalidPayloadError('The payload should be a open or close websocket message.') + } + const { userId, conversationId } = await extractRequestIdentifiers(props, req) + const channels = [conversationId, userId] + + const { participant } = await props.apiUtils.findParticipant({ id: conversationId, userId }) + if (!participant) { + throw new errors.ForbiddenError('You are not a participant in this conversation') + } + + for (const message of messages.parse(Buffer.from(req.body))) { + if (message.type === 'open') { + const response = new outputs.ResponseBuilder().open().keepAlive('ping', 30).subscribe(channels).toResponse() + return { + ...response, + body: response.body.toString(), + } + } + if (message.type === 'close' || message.type === 'disconnect') { + const code = 'code' in message ? message.code : WS_CLOSE_GOING_AWAY + const response = new outputs.ResponseBuilder().close(code).unsubscribe(channels).toResponse() + return { + ...response, + body: response.body.toString(), + } + } + } + throw new errors.InvalidPayloadError('The payload should be a open or close websocket message.') +} diff --git a/packages/chat-client/e2e/message.test.ts b/packages/chat-client/e2e/message.test.ts index c80c7ca7266..9fe8e7af006 100644 --- a/packages/chat-client/e2e/message.test.ts +++ b/packages/chat-client/e2e/message.test.ts @@ -6,10 +6,12 @@ import * as chat from '../src' const apiUrl = config.get('API_URL') const encryptionKey = config.get('ENCRYPTION_KEY') +const serverEventsProtocols = ['sse', 'websocket'] as const type CheckApiCanSendAndReceiveMessagesProps = { client: chat.AuthenticatedClient conversationId: string + protocol: chat.ServerEventsProtocol } type MessagePayload = chat.AuthenticatedClientRequests['createMessage']['payload'] @@ -22,6 +24,7 @@ const checkApiCanSendAndReceiveMessages = async ( const listener = await client.listenConversation({ id: conversationId, + protocol: props.protocol, }) const waitForResponsePromise = new Promise((resolve) => { @@ -58,7 +61,7 @@ const checkApiCanSendAndReceiveMessages = async ( return messageReceived.payload } -test('api allows sending and receiving messages using botpress IDs', async () => { +test.each(serverEventsProtocols)('api allows sending and receiving messages using botpress IDs', async (protocol) => { const client = await chat.Client.connect({ apiUrl }) const { @@ -69,6 +72,7 @@ test('api allows sending and receiving messages using botpress IDs', async () => { client, conversationId, + protocol, }, { type: 'text', @@ -77,7 +81,7 @@ test('api allows sending and receiving messages using botpress IDs', async () => ) }) -test('api allows sending and receiving messages using foreign IDs', async () => { +test.each(serverEventsProtocols)('api allows sending and receiving messages using foreign IDs', async (protocol) => { const userId = utils.getUserFid() const conversationId = utils.getConversationFid() const client = await chat.Client.connect({ apiUrl, userId }) @@ -88,6 +92,7 @@ test('api allows sending and receiving messages using foreign IDs', async () => { client, conversationId, + protocol, }, { type: 'text', @@ -96,31 +101,35 @@ test('api allows sending and receiving messages using foreign IDs', async () => ) }) -test('api allows sending and receiving messages using remotly generated JWTs', async () => { - const userId = utils.getUserFid() - const conversationId = utils.getConversationFid() +test.each(serverEventsProtocols)( + 'api allows sending and receiving messages using remotly generated JWTs', + async (protocol) => { + const userId = utils.getUserFid() + const conversationId = utils.getConversationFid() - const client = await chat.Client.connect({ apiUrl, userId, encryptionKey }) + const client = await chat.Client.connect({ apiUrl, userId, encryptionKey }) - await client.getOrCreateConversation({ id: conversationId }) + await client.getOrCreateConversation({ id: conversationId }) - await checkApiCanSendAndReceiveMessages( - { - client, - conversationId, - }, - { - type: 'text', - text: 'hello world', - } - ) -}) + await checkApiCanSendAndReceiveMessages( + { + client, + conversationId, + protocol, + }, + { + type: 'text', + text: 'hello world', + } + ) + } +) -test('api allows deleting a message', async () => { +test.each(serverEventsProtocols)('api allows deleting a message', async (protocol) => { const client = await chat.Client.connect({ apiUrl }) const { conversation } = await client.createConversation({}) - const signalListener = await client.listenConversation({ id: conversation.id }) + const signalListener = await client.listenConversation({ id: conversation.id, protocol }) const [{ isBot, ...createdMessage }] = await Promise.all([ utils.waitFor(signalListener, 'message_created'), @@ -155,7 +164,7 @@ test('api allows deleting a message', async () => { ).rejects.toThrow(chat.ResourceNotFoundError) }) -test('api allows sending and receiving messages with metadata', async () => { +test.each(serverEventsProtocols)('api allows sending and receiving messages with metadata', async (protocol) => { type Message = Awaited>['messages'][number] const metadata = { foo: 'bar' } @@ -170,7 +179,7 @@ test('api allows sending and receiving messages with metadata', async () => { metadata, }) - const listener = await client.listenConversation({ id: conversationId }) + const listener = await client.listenConversation({ id: conversationId, protocol }) const receiveSelfMessagePromise = new Promise((resolve) => listener.on('message_created', (m) => { @@ -210,7 +219,7 @@ test('api allows sending and receiving messages with metadata', async () => { expect(fetchedSelfMessage!.metadata).toEqual(metadata) }) -test('api allows sending bloc messages', async () => { +test.each(serverEventsProtocols)('api allows sending bloc messages', async (protocol) => { const client = await chat.Client.connect({ apiUrl }) const { @@ -221,6 +230,7 @@ test('api allows sending bloc messages', async () => { { client, conversationId, + protocol, }, { type: 'bloc', @@ -238,7 +248,7 @@ test('api allows sending bloc messages', async () => { ) }) -test('api allows receiving bloc messages from bot', async () => { +test.each(serverEventsProtocols)('api allows receiving bloc messages from bot', async (protocol) => { const client = await chat.Client.connect({ apiUrl }) const { @@ -246,7 +256,7 @@ test('api allows receiving bloc messages from bot', async () => { } = await client.createConversation({}) const responsePayload: MessagePayload = await checkApiCanSendAndReceiveMessages( - { client, conversationId }, + { client, conversationId, protocol }, { type: 'text', text: 'bloc' } ) diff --git a/packages/chat-client/e2e/participant.test.ts b/packages/chat-client/e2e/participant.test.ts index 9a9777a1c36..96d8b0c5cc7 100644 --- a/packages/chat-client/e2e/participant.test.ts +++ b/packages/chat-client/e2e/participant.test.ts @@ -6,7 +6,9 @@ import * as chat from '../src' const apiUrl = config.get('API_URL') -test('api allows adding and removing conversation participants', async () => { +const protocols = ['sse', 'websocket'] as const + +test.each(protocols)('api allows adding and removing conversation participants', async (protocol) => { const client = new chat.Client({ apiUrl }) const { key: userKey1 } = await client.createUser({}) @@ -14,7 +16,7 @@ test('api allows adding and removing conversation participants', async () => { const { conversation } = await client.createConversation({ 'x-user-key': userKey1 }) - const listener = await client.listenConversation({ id: conversation.id, 'x-user-key': userKey1 }) + const listener = await client.listenConversation({ id: conversation.id, 'x-user-key': userKey1, protocol }) await expect( client.createMessage({ @@ -86,7 +88,7 @@ test('api forbids non-participants from listing participants', async () => { await expect(promise3).rejects.toThrow(chat.ForbiddenError) }) -test('signal listener is disconnected when participant is removed', async () => { +test.each(protocols)('signal listener is disconnected when participant is removed', async (protocol) => { const client = new chat.Client({ apiUrl }) const { user: user1, key: userKey1 } = await client.createUser({}) @@ -95,8 +97,8 @@ test('signal listener is disconnected when participant is removed', async () => const { conversation } = await client.createConversation({ 'x-user-key': userKey1 }) await client.addParticipant({ 'x-user-key': userKey1, conversationId: conversation.id, userId: user2.id }) - const listener1 = await client.listenConversation({ id: conversation.id, 'x-user-key': userKey1 }) - const listener2 = await client.listenConversation({ id: conversation.id, 'x-user-key': userKey2 }) + const listener1 = await client.listenConversation({ id: conversation.id, 'x-user-key': userKey1, protocol }) + const listener2 = await client.listenConversation({ id: conversation.id, 'x-user-key': userKey2, protocol }) const messages1: chat.Signals['message_created'][] = [] const messages2: chat.Signals['message_created'][] = [] diff --git a/packages/chat-client/package.json b/packages/chat-client/package.json index 9aacd47da1c..e27060c566b 100644 --- a/packages/chat-client/package.json +++ b/packages/chat-client/package.json @@ -2,13 +2,13 @@ "name": "@botpress/chat", "version": "0.5.5", "description": "Botpress Chat API Client", - "main": "./dist/index.cjs", - "module": "./dist/index.mjs", - "types": "./dist/index.d.ts", "license": "MIT", "repository": { "url": "https://github.com/botpress/botpress" }, + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", "scripts": { "check:type": "tsc --noEmit", "generate": "ts-node -T ./openapi.ts ./src/gen", @@ -39,6 +39,7 @@ "@types/uuid": "^9.0.1", "@types/verror": "^1.10.6", "@types/web": "^0.0.115", + "@types/ws": "^8.5.10", "dotenv": "^16.4.4", "esbuild": "^0.25.10", "esbuild-plugin-polyfill-node": "^0.3.0", diff --git a/packages/chat-client/src/client.ts b/packages/chat-client/src/client.ts index f48ef91b967..21be999076d 100644 --- a/packages/chat-client/src/client.ts +++ b/packages/chat-client/src/client.ts @@ -2,6 +2,7 @@ import axios from 'axios' import { isBrowser } from 'browser-or-node' import * as consts from './consts' import * as errors from './errors' +import { ServerEventsProtocol } from './eventsource' import { apiVersion, Client as AutoGeneratedClient } from './gen/client' import jwt from './jsonwebtoken' import { AsyncCollection } from './listing' @@ -21,7 +22,9 @@ type IClient = Merge< [K in types.ClientOperation]: (x: types.ClientRequests[K]) => Promise }, { - listenConversation: (args: types.ClientRequests['listenConversation']) => Promise + listenConversation: ( + args: types.ClientRequests['listenConversation'] & { protocol?: ServerEventsProtocol } + ) => Promise } > @@ -30,7 +33,9 @@ type IAuthenticatedClient = Merge< [K in types.AuthenticatedOperation]: (x: types.AuthenticatedClientRequests[K]) => Promise }, { - listenConversation: (args: types.AuthenticatedClientRequests['listenConversation']) => Promise + listenConversation: ( + args: types.AuthenticatedClientRequests['listenConversation'] & { protocol?: ServerEventsProtocol } + ) => Promise } > @@ -121,12 +126,17 @@ export class Client implements IClient { } } - public readonly listenConversation: IClient['listenConversation'] = async ({ id, 'x-user-key': userKey }) => { + public readonly listenConversation: IClient['listenConversation'] = async ({ + id, + 'x-user-key': userKey, + protocol, + }) => { const signalListener = await SignalListener.listen({ url: this._apiUrl, conversationId: id, userKey, debug: this.props.debug ?? false, + protocol: protocol ?? 'sse', }) return signalListener } diff --git a/packages/chat-client/src/eventsource.ts b/packages/chat-client/src/eventsource.ts index 0de0c464f3b..99288721985 100644 --- a/packages/chat-client/src/eventsource.ts +++ b/packages/chat-client/src/eventsource.ts @@ -3,6 +3,16 @@ import type EventSourceBrowser from 'event-source-polyfill' import type EventSourceNodeJs from 'eventsource' import { EventEmitter } from './event-emitter' +type WebSocketOnOpen = NonNullable +type WebSocketOnMessage = NonNullable +type WebSocketOnError = NonNullable +type WebSocketOnClose = NonNullable + +type WebSocketOpenEvent = Parameters[0] +type WebSocketMessageEvent = Parameters[0] +type WebSocketErrorEvent = Parameters[0] +type WebSocketCloseEvent = Parameters[0] + type NodeOnOpen = EventSourceNodeJs['onopen'] type NodeOnMessage = EventSourceNodeJs['onmessage'] type NodeOnError = EventSourceNodeJs['onerror'] @@ -19,45 +29,53 @@ type BrowserOpenEvent = Parameters[0] type BrowserMessageEvent = Parameters[0] type BrowserErrorEvent = Parameters[0] -export type OpenEvent = NodeOpenEvent | BrowserOpenEvent -export type MessageEvent = NodeMessageEvent | BrowserMessageEvent -export type ErrorEvent = NodeErrorEvent | BrowserErrorEvent +export type OpenEvent = NodeOpenEvent | BrowserOpenEvent | WebSocketOpenEvent +export type MessageEvent = NodeMessageEvent | BrowserMessageEvent | WebSocketMessageEvent +export type ErrorEvent = NodeErrorEvent | BrowserErrorEvent | WebSocketErrorEvent +export type CloseEvent = WebSocketCloseEvent export type Events = { open: OpenEvent message: MessageEvent error: ErrorEvent + close: CloseEvent } +export type ServerEventsProtocol = 'websocket' | 'sse' + export type Props = { headers?: Record + protocol?: ServerEventsProtocol } +type ServerEventsSource = EventSourceBrowser.EventSourcePolyfill | EventSourceNodeJs | WebSocket + const makeEventSource = (url: string, props: Props = {}) => { - if (isBrowser) { - const module: typeof EventSourceBrowser = require('event-source-polyfill') - const ctor = module.EventSourcePolyfill - const source = new ctor(url, { headers: props.headers }) - const emitter = new EventEmitter() - source.onopen = (ev) => emitter.emit('open', ev) - source.onmessage = (ev) => emitter.emit('message', ev) - source.onerror = (ev) => emitter.emit('error', ev) - return { - emitter, - source, + let source: ServerEventsSource + const emitter = new EventEmitter() + if (props.protocol === 'websocket') { + url = url.replace(/^http/, 'ws') + if (props.headers?.['x-user-key']) { + url = `${url}?x-user-key=${encodeURIComponent(props.headers['x-user-key'])}` } + source = new WebSocket(url) + source.onclose = (ev: CloseEvent) => emitter.emit('close', ev) } else { - const module: typeof EventSourceNodeJs = require('eventsource') - const source = new module(url, { headers: props.headers }) - const emitter = new EventEmitter() - source.onopen = (ev) => emitter.emit('open', ev) - source.onmessage = (ev) => emitter.emit('message', ev) - source.onerror = (ev) => emitter.emit('error', ev) - return { - emitter, - source, + if (isBrowser) { + const module: typeof EventSourceBrowser = require('event-source-polyfill') + source = new module.EventSourcePolyfill(url, { headers: props.headers }) + } else { + const module: typeof EventSourceNodeJs = require('eventsource') + source = new module(url, { headers: props.headers }) } } + source.onopen = (ev: OpenEvent) => emitter.emit('open', ev) + source.onmessage = (ev: MessageEvent) => emitter.emit('message', ev) + source.onerror = (ev: ErrorEvent) => emitter.emit('error', ev) + return { + emitter, + source, + } } export type EventSourceEmitter = { @@ -75,6 +93,9 @@ export const listenEventSource = async (url: string, props: Props = {}): Promise emitter.on('error', (thrown) => { reject(thrown) }) + emitter.on('close', () => { + reject(new Error('Connection closed before opening')) + }) }).finally(() => emitter.cleanup()) return { diff --git a/packages/chat-client/src/index.ts b/packages/chat-client/src/index.ts index 7aeb6b1aca6..527675ea349 100644 --- a/packages/chat-client/src/index.ts +++ b/packages/chat-client/src/index.ts @@ -3,3 +3,4 @@ export * from './types' export * from './errors' export * from './client' export * from './signal-listener' +export { ServerEventsProtocol } from './eventsource' diff --git a/packages/chat-client/src/signal-listener.ts b/packages/chat-client/src/signal-listener.ts index f735e06b22a..8e9189e1e9a 100644 --- a/packages/chat-client/src/signal-listener.ts +++ b/packages/chat-client/src/signal-listener.ts @@ -1,5 +1,5 @@ import { EventEmitter } from './event-emitter' -import { listenEventSource, EventSourceEmitter, MessageEvent, ErrorEvent } from './eventsource' +import { listenEventSource, EventSourceEmitter, MessageEvent, ErrorEvent, ServerEventsProtocol } from './eventsource' import { zod as signals, Types } from './gen/signals' import { WatchDog } from './watchdog' @@ -35,6 +35,7 @@ export type Signals = { type Events = Signals & { error: Error + close: undefined } export type SignalListenerStatus = SignalListenerState['status'] @@ -44,6 +45,7 @@ export type SignalListenerProps = { userKey: string conversationId: string debug: boolean + protocol?: ServerEventsProtocol } export class SignalListener extends EventEmitter { @@ -100,12 +102,14 @@ export class SignalListener extends EventEmitter { private _connect = async (): Promise => { const source = await listenEventSource(`${this._props.url}/conversations/${this._props.conversationId}/listen`, { headers: { 'x-user-key': this._props.userKey }, + protocol: this._props.protocol, }) const watchdog = WatchDog.init(CONNECTION_TIMEOUT) source.on('message', this._handleMessage(source, watchdog)) source.on('error', this._handleError(source, watchdog)) + source.on('close', this._handleClose(source, watchdog)) watchdog.on('error', this._handleError(source, watchdog)) this._state = { status: 'connected', source, watchdog } @@ -124,6 +128,11 @@ export class SignalListener extends EventEmitter { this.emit(signal.type, signal.data) } + private _handleClose = (source: EventSourceEmitter, watchdog: WatchDog) => () => { + this._disconnectSync(source, watchdog) + this.emit('close', undefined) + } + private _handleError = (source: EventSourceEmitter, watchdog: WatchDog) => (ev: ErrorEvent | Error) => { this._disconnectSync(source, watchdog) const err = this._toError(ev) diff --git a/packages/cli/src/chat/index.ts b/packages/cli/src/chat/index.ts index cdbdb65b459..9845aacc2c5 100644 --- a/packages/cli/src/chat/index.ts +++ b/packages/cli/src/chat/index.ts @@ -43,6 +43,7 @@ const EXIT_KEYWORDS = ['exit', '.exit'] export type ChatProps = { client: chat.AuthenticatedClient conversationId: string + protocol: chat.ServerEventsProtocol } export class Chat { @@ -61,7 +62,10 @@ export class Chat { this._switchAlternateScreenBuffer() this._events.on('state', this._renderMessages) - const connection = await this._props.client.listenConversation({ id: this._props.conversationId }) + const connection = await this._props.client.listenConversation({ + id: this._props.conversationId, + protocol: this._props.protocol, + }) const keyboard = readline.createInterface({ input: process.stdin, output: process.stdout, diff --git a/packages/cli/src/command-implementations/chat-command.ts b/packages/cli/src/command-implementations/chat-command.ts index 647911c6ee2..35b630e0680 100644 --- a/packages/cli/src/command-implementations/chat-command.ts +++ b/packages/cli/src/command-implementations/chat-command.ts @@ -55,7 +55,7 @@ export class ChatCommand extends GlobalCommand { convLine.success(`Conversation created with id "${conversation.id}"`) convLine.commit() - const chat = Chat.launch({ client, conversationId: conversation.id }) + const chat = Chat.launch({ client, conversationId: conversation.id, protocol: this.argv.protocol }) await chat.wait() } diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index ff37e5e89c2..d0f9c8bcf2a 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -1,3 +1,4 @@ +import type { ServerEventsProtocol } from '@botpress/chat' import * as consts from './consts' import { ProjectTemplates } from './project-templates' import type { CommandOption, CommandSchema } from './typings' @@ -83,9 +84,17 @@ const pluginRef = { description: 'The plugin ID or name and version. Ex: knowledge@0.0.1', } satisfies CommandOption -const sourceMap = { type: 'boolean', description: 'Generate sourcemaps', default: false } satisfies CommandOption +const sourceMap = { + type: 'boolean', + description: 'Generate sourcemaps', + default: false, +} satisfies CommandOption -const minify = { type: 'boolean', description: 'Minify the bundled code', default: true } satisfies CommandOption +const minify = { + type: 'boolean', + description: 'Minify the bundled code', + default: true, +} satisfies CommandOption const dev = { type: 'boolean', @@ -175,7 +184,10 @@ const deploySchema = { botId: { type: 'string', description: 'The bot ID to deploy. Only used when deploying a bot' }, noBuild, dryRun, - createNewBot: { type: 'boolean', description: 'Create a new bot when deploying. Only used when deploying a bot' }, + createNewBot: { + type: 'boolean', + description: 'Create a new bot when deploying. Only used when deploying a bot', + }, sourceMap, minify, visibility: { @@ -196,7 +208,10 @@ const deploySchema = { description: 'Allow deprecated features in the project', default: false, }, - url: { type: 'string', description: 'Custom URL for the integration. Only used when deploying an integration' }, + url: { + type: 'string', + description: 'Custom URL for the integration. Only used when deploying an integration', + }, } as const satisfies CommandSchema const devSchema = { @@ -247,7 +262,12 @@ const removeSchema = { ...globalSchema, ...credentialsSchema, workDir, - alias: { idx: 0, positional: true, type: 'string', description: 'The alias of the package to uninstall' }, + alias: { + idx: 0, + positional: true, + type: 'string', + description: 'The alias of the package to uninstall', + }, } satisfies CommandSchema const loginSchema = { @@ -378,16 +398,29 @@ const chatSchema = { idx: 0, description: 'The bot ID to chat with', }, + protocol: { + choices: ['sse', 'websocket'] satisfies ReadonlyArray, + default: 'sse' as const, + description: 'The protocol to use for long lived connections', + }, } satisfies CommandSchema const listProfilesSchema = { ...globalSchema, - displayToken: { type: 'boolean', description: 'Display the token in each of the bp profiles', default: false }, + displayToken: { + type: 'boolean', + description: 'Display the token in each of the bp profiles', + default: false, + }, } satisfies CommandSchema const activeProfileSchema = { ...globalSchema, - displayToken: { type: 'boolean', description: 'Display the token in the bp profile', default: false }, + displayToken: { + type: 'boolean', + description: 'Display the token in the bp profile', + default: false, + }, } satisfies CommandSchema const useProfileSchema = { @@ -408,7 +441,11 @@ const getProfileSchema = { positional: true, idx: 0, }, - displayToken: { type: 'boolean', description: 'Display the token in the bp profile', default: false }, + displayToken: { + type: 'boolean', + description: 'Display the token in the bp profile', + default: false, + }, } satisfies CommandSchema // exports diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dee5594d4d0..929cb0c0e99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -706,6 +706,9 @@ importers: '@botpress/sdk': specifier: workspace:* version: link:../../packages/sdk + '@bpinternal/pingrip': + specifier: 0.1.1 + version: 0.1.1 '@opentelemetry/api': specifier: 1.9.0 version: 1.9.0 @@ -2639,6 +2642,9 @@ importers: '@types/web': specifier: ^0.0.115 version: 0.0.115 + '@types/ws': + specifier: ^8.5.10 + version: 8.18.1 dotenv: specifier: ^16.4.4 version: 16.4.4 @@ -4203,6 +4209,9 @@ packages: resolution: {integrity: sha512-r38k8SZSyu0s4YKukTrHaBeb8tlL8vgJiNdawnN2cmxbqkSdsss2pWnZQU2fsSLijhcfV0tUvGCf8GfgZviYBA==} engines: {node: '>=16.0.0', pnpm: 8.6.2} + '@bpinternal/pingrip@0.1.1': + resolution: {integrity: sha512-bidpZO+d1fAAp0MvoLRhgvJyVu57S+B74WKzPMk5i/8rgaPSUySGnXqH52gFA27oyZnEicZGBte4U7+iq00p3A==} + '@bpinternal/readiness@0.0.16': resolution: {integrity: sha512-VkxUYCblFVm0S39kNT/ycFmHXN/OGPuMnbOpPWPgYf/dOLJgtM0oWsW8TOs8JcO+Pt928r9vonbMFYRGYDwQpw==} engines: {node: '>=16.0.0', pnpm: 8.6.2} @@ -6732,8 +6741,8 @@ packages: '@types/ws@6.0.4': resolution: {integrity: sha512-PpPrX7SZW9re6+Ha8ojZG4Se8AZXgf0GK6zmfqEuCsY49LFDNXO3SByp44X3dFEqtB73lkCDAdUazhAjVPiNwg==} - '@types/ws@8.5.5': - resolution: {integrity: sha512-lwhs8hktwxSjf9UaZ9tG5M03PGogvFaH8gUgLNbN9HKIg0dvv6q+gkSuJ8HN4/VbyxkuLzCjlN7GquQ0gUJfIg==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} '@types/yargs-parser@21.0.0': resolution: {integrity: sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==} @@ -12425,18 +12434,6 @@ packages: utf-8-validate: optional: true - ws@8.13.0: - resolution: {integrity: sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - ws@8.18.2: resolution: {integrity: sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==} engines: {node: '>=10.0.0'} @@ -14009,6 +14006,12 @@ snapshots: - openapi-types - supports-color + '@bpinternal/pingrip@0.1.1': + dependencies: + axios: 1.13.6 + transitivePeerDependencies: + - debug + '@bpinternal/readiness@0.0.16': dependencies: '@aws-sdk/client-dynamodb': 3.709.0 @@ -14043,10 +14046,10 @@ snapshots: '@bpinternal/tunnel@0.1.1': dependencies: - '@types/ws': 8.5.5 + '@types/ws': 8.18.1 browser-or-node: 2.1.1 - isomorphic-ws: 5.0.0(ws@8.13.0) - ws: 8.13.0 + isomorphic-ws: 5.0.0(ws@8.20.0) + ws: 8.20.0 zod: 3.24.2 transitivePeerDependencies: - bufferutil @@ -16755,7 +16758,7 @@ snapshots: dependencies: '@types/node': 22.16.4 - '@types/ws@8.5.5': + '@types/ws@8.18.1': dependencies: '@types/node': 22.16.4 @@ -20067,9 +20070,9 @@ snapshots: transitivePeerDependencies: - encoding - isomorphic-ws@5.0.0(ws@8.13.0): + isomorphic-ws@5.0.0(ws@8.20.0): dependencies: - ws: 8.13.0 + ws: 8.20.0 isstream@0.1.2: {} @@ -23938,12 +23941,9 @@ snapshots: ws@7.5.10: {} - ws@8.13.0: {} - ws@8.18.2: {} - ws@8.20.0: - optional: true + ws@8.20.0: {} wsl-utils@0.1.0: dependencies: From 449880b6fe18d9422d31154440c8e840d199d6af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Gagn=C3=A9=20Cliche?= <48337098+FelixGCliche@users.noreply.github.com> Date: Tue, 28 Apr 2026 09:52:10 -0400 Subject: [PATCH 2/5] feat!(anthropic): Add Opus 4.7 (#15135) --- integrations/anthropic/integration.definition.ts | 2 +- .../anthropic/src/actions/generate-content.ts | 6 ++++-- integrations/anthropic/src/index.ts | 14 ++++++++++++++ integrations/anthropic/src/schemas.ts | 1 + 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/integrations/anthropic/integration.definition.ts b/integrations/anthropic/integration.definition.ts index bee842ce946..6857b36cd00 100644 --- a/integrations/anthropic/integration.definition.ts +++ b/integrations/anthropic/integration.definition.ts @@ -6,7 +6,7 @@ export default new IntegrationDefinition({ name: 'anthropic', title: 'Anthropic', description: 'Access a curated list of Claude models to set as your chosen LLM.', - version: '17.0.0', + version: '18.0.0', readme: 'hub.md', icon: 'icon.svg', entities: { diff --git a/integrations/anthropic/src/actions/generate-content.ts b/integrations/anthropic/src/actions/generate-content.ts index a626bf08f8b..7261c795a34 100644 --- a/integrations/anthropic/src/actions/generate-content.ts +++ b/integrations/anthropic/src/actions/generate-content.ts @@ -103,11 +103,13 @@ export async function generateContent( (modelId === 'claude-sonnet-4-5-20250929' || modelId === 'claude-haiku-4-5-20251001' || modelId === 'claude-sonnet-4-6' || - modelId === 'claude-opus-4-6') && + modelId === 'claude-opus-4-6' || + modelId === 'claude-opus-4-7') && request.temperature !== undefined && request.top_p !== undefined ) { - // This model fails when setting both parameters with the error "`temperature` and `top_p` cannot both be specified for this model. Please use only one.", so we remove the top_p parameter if temperature is also set. + // TODO: Remove this check once all 3.x models are removed, + // see https://platform.claude.com/docs/en/about-claude/models/migration-guide#breaking-changes request.top_p = undefined } diff --git a/integrations/anthropic/src/index.ts b/integrations/anthropic/src/index.ts index ba3895fba4c..a43fcb5fd86 100644 --- a/integrations/anthropic/src/index.ts +++ b/integrations/anthropic/src/index.ts @@ -34,6 +34,20 @@ const LanguageModels: Record = { // NOTE: We don't support returning "thinking" blocks from Claude in the integration action output as the concept of "thinking" blocks is a Claude-specific feature that other providers don't have. For now we won't support this as an official feature in the integration so it needs to be taken into account when using reasoning mode and passing a multi-turn conversation history in the generateContent action input. // For more information, see: https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#preserving-thinking-blocks // NOTE: We intentionally didn't include the Opus model as it's the most expensive model in the market, it's not very popular, and no users have ever requested it so far. + 'claude-opus-4-7': { + name: 'Claude Opus 4.7', + description: + "Claude Opus 4.7 is Anthropic's most capable model to date. Building on Opus 4.6, it advances frontier coding, agentic reasoning, and enterprise workflows.", + tags: ['recommended', 'reasoning', 'agents', 'vision', 'general-purpose', 'coding'], + input: { + costPer1MTokens: 5, + maxTokens: 1_000_000, + }, + output: { + costPer1MTokens: 15, + maxTokens: 128_000, + }, + }, 'claude-opus-4-6': { name: 'Claude Opus 4.6', description: diff --git a/integrations/anthropic/src/schemas.ts b/integrations/anthropic/src/schemas.ts index 22982949f9a..af5c956c9b4 100644 --- a/integrations/anthropic/src/schemas.ts +++ b/integrations/anthropic/src/schemas.ts @@ -6,6 +6,7 @@ export const DefaultModel: ModelId = 'claude-sonnet-4-5-20250929' export const ModelId = z .enum([ + 'claude-opus-4-7', 'claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5-20251001', From 6aff3f8857785bca195a76000638f0e1e791ba26 Mon Sep 17 00:00:00 2001 From: Sergei Kofman <52934469+sergeikofman444@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:02:05 -0400 Subject: [PATCH 3/5] wizard copy change (#15143) --- integrations/slack/integration.definition.ts | 2 +- integrations/slack/src/oauth-wizard/wizard.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/integrations/slack/integration.definition.ts b/integrations/slack/integration.definition.ts index 7761bd886c2..2fb846f9538 100644 --- a/integrations/slack/integration.definition.ts +++ b/integrations/slack/integration.definition.ts @@ -14,7 +14,7 @@ export default new IntegrationDefinition({ name: 'slack', title: 'Slack', description: 'Automate interactions with your team.', - version: '5.0.0', + version: '5.0.1', icon: 'icon.svg', readme: 'hub.md', configuration: { diff --git a/integrations/slack/src/oauth-wizard/wizard.ts b/integrations/slack/src/oauth-wizard/wizard.ts index e0baba0d476..a7383e99d11 100644 --- a/integrations/slack/src/oauth-wizard/wizard.ts +++ b/integrations/slack/src/oauth-wizard/wizard.ts @@ -44,7 +44,7 @@ const _manualCredentialsForm = { pageTitle: 'Slack App Credentials', htmlOrMarkdownPageContents: 'Enter your Slack app credentials.
' + - 'You can find these in the app admin panel under Basic Information.', + 'You can find these in the app admin panel under each application\'s Basic Information.', schema: _manualCredentialsSchema, nextStepId: 'save-manual-credentials', } @@ -52,8 +52,8 @@ const _manualCredentialsForm = { const _manifestConfigForm = { pageTitle: 'Slack App Configuration', htmlOrMarkdownPageContents: - 'Enter your Slack App Configuration Refresh Token and a name for your app.
' + - 'You can generate tokens at api.slack.com/apps.', + 'Generate an App Configuration in the app admin panel, near the bottom of the page.
' + + 'Enter your Slack App Configuration Refresh Token, and a name for your app.', schema: _manifestConfigSchema, nextStepId: 'create-app', } @@ -79,9 +79,9 @@ const _startHandler: WizardHandler = ({ responses }) => { pageTitle: 'Slack Integration Setup', htmlOrMarkdownPageContents: 'Choose how you would like to configure your Slack integration:', choices: [ - { label: 'Use the default Botpress Slack Application', value: 'default' }, + { label: 'Connect with OAuth', value: 'default' }, { label: 'Configure a new Slack Application', value: 'manifest' }, - { label: 'Use an already existing Slack Application', value: 'manual' }, + { label: 'Use existing Slack Application Credentials', value: 'manual' }, ], nextStepId: 'route-choice', }) From be912b8bd31732e16184039a6ba3ba147d985285 Mon Sep 17 00:00:00 2001 From: Brent Williams Date: Tue, 28 Apr 2026 08:34:11 -0700 Subject: [PATCH 4/5] fix: honor Retry-After header in CLI retry delay for 429 responses (#15145) --- packages/cli/src/api/retry.ts | 39 ++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/api/retry.ts b/packages/cli/src/api/retry.ts index a5289ad5868..7ca26cae47a 100644 --- a/packages/cli/src/api/retry.ts +++ b/packages/cli/src/api/retry.ts @@ -2,10 +2,47 @@ import * as client from '@botpress/client' // TODO: we probably shouldnt retry on 500 errors, but this is a temporary fix for the botpress repo CI const HTTP_STATUS_TO_RETRY_ON = [429, 500, 502, 503, 504] + +function getRetryAfterMs(error: Parameters>[1]): number | undefined { + const headers = error?.response?.headers + if (!headers) return undefined + + const headerNames = ['retry-after', 'ratelimit-reset', 'x-ratelimit-reset'] + for (const name of headerNames) { + const raw = headers[name] + if (!raw) continue + const value = String(raw) + + // HTTP-date format (e.g. "Mon, 28 Apr 2026 12:00:00 GMT") + if (value.includes(' ')) { + const futureDate = new Date(value) + if (!isNaN(futureDate.getTime())) { + return Math.max(0, futureDate.getTime() - Date.now()) + } + continue + } + + // Seconds format (e.g. "120") + const seconds = parseInt(value, 10) + if (!isNaN(seconds) && seconds >= 0) { + return seconds * 1000 + } + } + return undefined +} + export const config: client.RetryConfig = { retries: 3, retryCondition: (err) => client.axiosRetry.isNetworkOrIdempotentRequestError(err) || HTTP_STATUS_TO_RETRY_ON.includes(err.response?.status ?? 0), - retryDelay: (retryCount) => retryCount * 1000, + retryDelay: (retryCount, error) => { + if (error?.response?.status === 429) { + const retryAfterMs = getRetryAfterMs(error) + if (retryAfterMs !== undefined) { + return retryAfterMs + } + } + return Math.max(retryCount, 1) * 1000 + }, } From e14a2bf5ff0eb1c8cf068c25b2845784b9f56c40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Gagn=C3=A9=20Cliche?= <48337098+FelixGCliche@users.noreply.github.com> Date: Tue, 28 Apr 2026 13:25:52 -0400 Subject: [PATCH 5/5] feat(slack): Support native video and audio messages in slack (#15138) --- integrations/slack/integration.definition.ts | 2 +- integrations/slack/src/channels.ts | 99 +++++++++++-------- integrations/slack/src/misc/utils.ts | 42 ++++++++ integrations/slack/src/setup.ts | 1 + .../slack/src/slack-api/slack-client.ts | 67 +++++++++++++ .../handlers/message-received.ts | 3 + 6 files changed, 173 insertions(+), 41 deletions(-) diff --git a/integrations/slack/integration.definition.ts b/integrations/slack/integration.definition.ts index 2fb846f9538..11f59affd74 100644 --- a/integrations/slack/integration.definition.ts +++ b/integrations/slack/integration.definition.ts @@ -14,7 +14,7 @@ export default new IntegrationDefinition({ name: 'slack', title: 'Slack', description: 'Automate interactions with your team.', - version: '5.0.1', + version: '5.0.2', icon: 'icon.svg', readme: 'hub.md', configuration: { diff --git a/integrations/slack/src/channels.ts b/integrations/slack/src/channels.ts index ed5eba1d455..1c3fb3a5bef 100644 --- a/integrations/slack/src/channels.ts +++ b/integrations/slack/src/channels.ts @@ -3,7 +3,7 @@ import { ChatPostMessageArguments } from '@slack/web-api' import { textSchema } from '../definitions/channels/text-input-schema' import { transformMarkdownForSlack } from './misc/markdown-to-slack' import { replaceMentions } from './misc/replace-mentions' -import { isValidUrl } from './misc/utils' +import { downloadBotpressFile, isValidUrl } from './misc/utils' import { SlackClient } from './slack-api' import { renderCard } from './slack-api/card-renderer' import * as bp from '.botpress' @@ -41,51 +41,15 @@ const defaultMessages = { }, audio: async ({ ctx, conversation, ack, client, payload, logger }) => { logger.forBot().debug('Sending audio message to Slack chat:', payload) - await _sendSlackMessage( - { ack, ctx, client, logger }, - { - ..._getSlackTarget(conversation), - text: 'audio', - blocks: [ - { - type: 'section', - text: { type: 'mrkdwn', text: `<${payload.audioUrl}|audio>` }, - }, - ], - } - ) + await _uploadSlackFile({ ack, ctx, client, logger, conversation }, { url: payload.audioUrl, title: payload.title }) }, video: async ({ ctx, conversation, ack, client, payload, logger }) => { logger.forBot().debug('Sending video message to Slack chat:', payload) - await _sendSlackMessage( - { ack, ctx, client, logger }, - { - ..._getSlackTarget(conversation), - text: 'video', - blocks: [ - { - type: 'section', - text: { type: 'mrkdwn', text: `<${payload.videoUrl}|video>` }, - }, - ], - } - ) + await _uploadSlackFile({ ack, ctx, client, logger, conversation }, { url: payload.videoUrl, title: payload.title }) }, file: async ({ ctx, conversation, ack, client, payload, logger }) => { logger.forBot().debug('Sending file message to Slack chat:', payload) - await _sendSlackMessage( - { ack, ctx, client, logger }, - { - ..._getSlackTarget(conversation), - text: 'file', - blocks: [ - { - type: 'section', - text: { type: 'mrkdwn', text: `<${payload.fileUrl}|file>` }, - }, - ], - } - ) + await _uploadSlackFile({ ack, ctx, client, logger, conversation }, { url: payload.fileUrl, title: payload.title }) }, location: async ({ ctx, conversation, ack, client, payload, logger }) => { const googleMapsLink = `https://www.google.com/maps/search/?api=1&query=${payload.latitude},${payload.longitude}` @@ -231,6 +195,61 @@ const _getOptionalProps = (ctx: bp.Context, logger: bp.Logger) => { return props } +const _uploadSlackFile = async ( + { + client, + ctx, + ack, + logger, + conversation, + }: { + client: bp.Client + ctx: bp.Context + ack: bp.AnyAckFunction + logger: bp.Logger + conversation: bp.ClientResponses['getConversation']['conversation'] + }, + { url, title }: { url: string; title?: string } +) => { + const { channel, thread_ts } = _getSlackTarget(conversation) + const { buffer, filename } = await downloadBotpressFile(url, client, logger) + + const slackClient = await SlackClient.createFromStates({ client, ctx, logger }) + + const oldestTs = (Date.now() / 1000 - 1).toFixed(6) + + await slackClient.uploadFile({ + channelId: channel, + threadTs: thread_ts, + fileBuffer: buffer, + filename, + title, + }) + + let messageTs: string | undefined + let messageUserId: string | undefined + try { + const message = await slackClient.getLatestChannelMessage({ + channelId: channel, + threadTs: thread_ts, + oldestTs, + }) + + if (message && message.user === slackClient.getBotUserId()) { + messageTs = message.ts + messageUserId = message.user + } else { + logger + .forBot() + .warn('Could not correlate uploaded Slack file with a bot message; thread/reaction tracking will be limited') + } + } catch (err) { + logger.forBot().warn(`Failed to retrieve uploaded file message metadata: ${err}`) + } + + await ack({ tags: { ts: messageTs, channelId: channel, userId: messageUserId } }) +} + const _sendSlackMessage = async ( { client, ctx, ack, logger }: { client: bp.Client; ctx: bp.Context; ack: bp.AnyAckFunction; logger: bp.Logger }, payload: ChatPostMessageArguments diff --git a/integrations/slack/src/misc/utils.ts b/integrations/slack/src/misc/utils.ts index a76deb7dbb6..307b643d831 100644 --- a/integrations/slack/src/misc/utils.ts +++ b/integrations/slack/src/misc/utils.ts @@ -1,6 +1,48 @@ +import { RuntimeError } from '@botpress/sdk' +import axios from 'axios' import { SlackClient } from 'src/slack-api' import * as bp from '.botpress' +const _extractBotpressFileId = (url: string): string | undefined => { + try { + const pathname = new URL(url).pathname + const last = pathname.split('/').filter(Boolean).pop() + return last ? decodeURIComponent(last) : undefined + } catch { + return undefined + } +} + +export const downloadBotpressFile = async ( + url: string, + client: bp.Client, + logger: bp.Logger +): Promise<{ buffer: Buffer; filename: string }> => { + let downloadUrl = url + let filename = _extractBotpressFileId(url) ?? 'file' + + const fileId = _extractBotpressFileId(url) + if (fileId) { + try { + const { file } = await client.getFile({ id: fileId }) + downloadUrl = file.url + filename = file.key.split('/').pop() ?? filename + } catch (error) { + logger + .forBot() + .debug('Could not resolve file through Botpress Files API, falling back to direct URL download', { error }) + } + } + + try { + const response = await axios.get(downloadUrl, { responseType: 'arraybuffer' }) + return { buffer: Buffer.from(response.data), filename } + } catch (error) { + logger.forBot().error('Error while downloading file from URL:', error) + throw new RuntimeError(error instanceof Error ? error.message : String(error)) + } +} + export const isValidUrl = (str: string) => { try { new URL(str) diff --git a/integrations/slack/src/setup.ts b/integrations/slack/src/setup.ts index 2ce88adc1d2..40aea8d291c 100644 --- a/integrations/slack/src/setup.ts +++ b/integrations/slack/src/setup.ts @@ -9,6 +9,7 @@ export const REQUIRED_SLACK_SCOPES = [ 'channels:read', 'chat:write', 'files:read', + 'files:write', 'groups:history', 'groups:read', 'groups:write', diff --git a/integrations/slack/src/slack-api/slack-client.ts b/integrations/slack/src/slack-api/slack-client.ts index fa999c5f5fd..dc961438805 100644 --- a/integrations/slack/src/slack-api/slack-client.ts +++ b/integrations/slack/src/slack-api/slack-client.ts @@ -242,6 +242,41 @@ export class SlackClient { return message } + @requireAllScopes(['channels:history', 'groups:history', 'im:history', 'mpim:history']) + @handleErrors('Failed to retrieve latest channel message') + public async getLatestChannelMessage({ + channelId, + threadTs, + oldestTs, + }: { + channelId: string + threadTs?: string + oldestTs?: string + }) { + if (threadTs) { + const { messages } = surfaceSlackErrors({ + logger: this._logger, + response: await this._slackWebClient.conversations.replies({ + channel: channelId, + ts: threadTs, + oldest: oldestTs, + }), + }) + return messages?.[messages.length - 1] + } + + const { messages } = surfaceSlackErrors({ + logger: this._logger, + response: await this._slackWebClient.conversations.history({ + channel: channelId, + limit: 1, + oldest: oldestTs, + }), + }) + + return messages?.[0] + } + @requireAllScopes(['im:write']) @handleErrors('Failed to start DM with user') public async startDmWithUser(channelId: string) { @@ -305,6 +340,38 @@ export class SlackClient { return response.message } + @requireAllScopes(['files:write']) + @handleErrors('Failed to upload file') + public async uploadFile({ + channelId, + threadTs, + fileBuffer, + filename, + title, + initialComment, + }: { + channelId: string + threadTs?: string + fileBuffer: Buffer + filename: string + title?: string + initialComment?: string + }) { + const response = surfaceSlackErrors({ + logger: this._logger, + response: await this._slackWebClient.files.uploadV2({ + channel_id: channelId, + thread_ts: threadTs, + file: fileBuffer, + filename, + title, + initial_comment: initialComment, + }), + }) + + return response + } + @requireAllScopes(['users:read']) @handleErrors('Failed to retrieve user profile') public async getUserProfile({ userId }: { userId: string }) { diff --git a/integrations/slack/src/webhook-events/handlers/message-received.ts b/integrations/slack/src/webhook-events/handlers/message-received.ts index 53ce4b3451e..c61b3b31026 100644 --- a/integrations/slack/src/webhook-events/handlers/message-received.ts +++ b/integrations/slack/src/webhook-events/handlers/message-received.ts @@ -286,6 +286,9 @@ const _parseSlackFile = (logger: bp.Logger, file: File): BlocItem | null => { case 'audio': return { type: fileType, payload: { audioUrl: file.permalink_public } } + case 'video': + return { type: fileType, payload: { videoUrl: file.permalink_public } } + case 'file': return { type: fileType, payload: { fileUrl: file.permalink_public } }