diff --git a/integrations/slack/definitions/channels/text-input-schema.ts b/integrations/slack/definitions/channels/text-input-schema.ts index 94ca4bd46e0..e8d5ce80278 100644 --- a/integrations/slack/definitions/channels/text-input-schema.ts +++ b/integrations/slack/definitions/channels/text-input-schema.ts @@ -395,6 +395,16 @@ const blocks = sdk.z.discriminatedUnion('type', [ // video // TODO: ]) +const mention = sdk.z.object({ + type: sdk.z.string(), + start: sdk.z.number(), // position in string + end: sdk.z.number(), + user: sdk.z.object({ + id: sdk.z.string(), + name: sdk.z.string(), + }), +}) + export const textSchema = sdk.z .object({ text: sdk.z @@ -410,5 +420,6 @@ export const textSchema = sdk.z .describe( 'Multiple blocks can be added to this array. If a block is provided, the text field is ignored and the text must be added as a block' ), + mentions: sdk.z.array(mention).optional(), }) .strict() diff --git a/integrations/slack/hub.md b/integrations/slack/hub.md index 22dba210b4d..c77ffaa33dc 100644 --- a/integrations/slack/hub.md +++ b/integrations/slack/hub.md @@ -1,5 +1,46 @@ The Slack integration enables seamless communication between your AI-powered chatbot and Slack, the popular collaboration platform. Connect your chatbot to Slack and streamline team communication, automate tasks, and enhance productivity. With this integration, your chatbot can send and receive messages, share updates, handle inquiries, and perform actions directly within Slack channels. Leverage Slack's extensive features such as chat, file sharing, notifications, and app integrations to create a powerful conversational AI experience. Enhance team collaboration and streamline workflows with the Slack Integration for Botpress. +## Migrating from version `2.x` to `3.x` + +Version 3.0 of the Slack integration changes the way the mention system works with Botpress. +It now swaps the mention text from slack to fullname and gives a infos about the mention. the payload looks like this: + +```JSON +{ + text: 'hey <@John Doe>!' + mentions: [ + { + type: 'user', + start: 6, + end: 14, + user: { + id: 'user_abc123', // This will be a botpress user id + name: 'John Doe' + } + } + ] +} +``` + +It will also do the same when the bot sends a string with mentions in it. The payload needs to look like this to work. + +```JSON +{ + text: 'hey <@John Doe>!' + mentions: [ + { + type: 'user', + start: 6, + end: 14, + user: { + id: 'U123', // This needs to be a slack member id + name: 'John Doe' + } + } + ] +} +``` + ## Migrating from version `1.x` to `2.x` Version 2.0 of the Slack integration introduces rotating authentication tokens. If you previously configured the integration using automatic configuration, no action is required once you update to the latest version. diff --git a/integrations/slack/integration.definition.ts b/integrations/slack/integration.definition.ts index d00b3b2c648..9cd1993c3bb 100644 --- a/integrations/slack/integration.definition.ts +++ b/integrations/slack/integration.definition.ts @@ -17,7 +17,7 @@ export default new IntegrationDefinition({ name: 'slack', title: 'Slack', description: 'Automate interactions with your team.', - version: '2.5.5', + version: '3.0.0', icon: 'icon.svg', readme: 'hub.md', configuration, diff --git a/integrations/slack/src/channels.ts b/integrations/slack/src/channels.ts index 3fcac5de47b..466b4839d86 100644 --- a/integrations/slack/src/channels.ts +++ b/integrations/slack/src/channels.ts @@ -1,6 +1,7 @@ import { RuntimeError } from '@botpress/client' import { ChatPostMessageArguments } from '@slack/web-api' import { textSchema } from '../definitions/channels/text-input-schema' +import { replaceMentions } from './misc/replace-mentions' import { isValidUrl } from './misc/utils' import { SlackClient } from './slack-api' import { renderCard } from './slack-api/card-renderer' @@ -9,6 +10,7 @@ import * as bp from '.botpress' const defaultMessages = { text: async ({ client, payload, ctx, conversation, ack, logger }) => { const parsed = textSchema.parse(payload) + parsed.text = replaceMentions(parsed.text, parsed.mentions) logger.forBot().debug('Sending text message to Slack chat:', payload) await _sendSlackMessage( { ack, ctx, client, logger }, diff --git a/integrations/slack/src/misc/replace-mentions.test.ts b/integrations/slack/src/misc/replace-mentions.test.ts new file mode 100644 index 00000000000..d0341872e5b --- /dev/null +++ b/integrations/slack/src/misc/replace-mentions.test.ts @@ -0,0 +1,33 @@ +import { test, expect, vi } from 'vitest' +import { replaceMentions, Mention } from './replace-mentions' + +test('returns undefined if text is undefined', () => { + expect(replaceMentions(undefined, [])).toBeUndefined() +}) + +test('returns text unchanged if mentions is undefined', () => { + expect(replaceMentions('hey <@John Doe>', undefined)).toBe('hey <@John Doe>') +}) + +test('replaces a single mention', () => { + const mentions: Mention[] = [{ start: 6, end: 10, user: { id: 'u1', name: 'John Doe' } }] + expect(replaceMentions('hey <@John Doe>', mentions)).toBe('hey <@u1>') +}) + +test('replaces multiple mentions', () => { + const mentions: Mention[] = [ + { start: 0, end: 5, user: { id: 'u1', name: 'John Doe' } }, + { start: 6, end: 11, user: { id: 'u2', name: 'Jane Doe' } }, + ] + expect(replaceMentions('hey <@John Doe> and <@Jane Doe>', mentions)).toBe('hey <@u1> and <@u2>') +}) + +test('does not replace if user name not found', () => { + const mentions: Mention[] = [{ start: 0, end: 4, user: { id: 'u1', name: 'nope' } }] + expect(replaceMentions('hey <@John Doe>', mentions)).toBe('hey <@John Doe>') +}) + +test('only replaces the first occurrence of a repeated name', () => { + const mentions: Mention[] = [{ start: 0, end: 4, user: { id: 'u1', name: 'John Doe' } }] + expect(replaceMentions('<@John Doe> <@John Doe> <@John Doe>', mentions)).toBe('<@u1> <@John Doe> <@John Doe>') +}) diff --git a/integrations/slack/src/misc/replace-mentions.ts b/integrations/slack/src/misc/replace-mentions.ts new file mode 100644 index 00000000000..7ca23940591 --- /dev/null +++ b/integrations/slack/src/misc/replace-mentions.ts @@ -0,0 +1,18 @@ +export type Mention = { + start: number + end: number + user: { id: string; name: string } +} + +export const replaceMentions = (text: string | undefined, mentions: Mention[] | undefined): string | undefined => { + if (!text || !mentions) { + return text + } + + mentions.sort((a, b) => b.start - a.start) + for (const mention of mentions) { + text = text.replace(mention.user.name, mention.user.id) + } + + return text +} diff --git a/integrations/slack/src/misc/utils.ts b/integrations/slack/src/misc/utils.ts index 4c4c798069c..2033a3ea583 100644 --- a/integrations/slack/src/misc/utils.ts +++ b/integrations/slack/src/misc/utils.ts @@ -1,3 +1,4 @@ +import { SlackClient } from 'src/slack-api' import * as bp from '.botpress' export const isValidUrl = (str: string) => { @@ -20,6 +21,33 @@ export const getBotpressUserFromSlackUser = async (props: { slackUserId: string } } +export const updateBotpressUserFromSlackUser = async ( + slackUserId: string, + botpressUser: Awaited>['user'], + client: bp.Client, + ctx: bp.Context, + logger: bp.Logger +) => { + if (botpressUser.pictureUrl && botpressUser.name) { + return + } + + try { + const slackClient = await SlackClient.createFromStates({ ctx, client, logger }) + const userProfile = await slackClient.getUserProfile({ userId: slackUserId }) + const fieldsToUpdate = { + pictureUrl: userProfile?.image_192, + name: userProfile?.real_name, + } + logger.forBot().debug('Fetched latest Slack user profile: ', fieldsToUpdate) + if (fieldsToUpdate.pictureUrl || fieldsToUpdate.name) { + await client.updateUser({ ...botpressUser, ...fieldsToUpdate }) + } + } catch (error) { + logger.forBot().error('Error while fetching user profile from Slack:', error) + } +} + export const getBotpressConversationFromSlackThread = async ( props: { slackChannelId: string; slackThreadId?: string }, client: bp.Client diff --git a/integrations/slack/src/webhook-events/handlers/message-received.ts b/integrations/slack/src/webhook-events/handlers/message-received.ts index 5bf5c1f4857..3d2e482d9f4 100644 --- a/integrations/slack/src/webhook-events/handlers/message-received.ts +++ b/integrations/slack/src/webhook-events/handlers/message-received.ts @@ -1,10 +1,35 @@ +import { z } from '@botpress/sdk' import { slackToMarkdown } from '@bpinternal/slackdown' -import { AllMessageEvents, FileShareMessageEvent, GenericMessageEvent } from '@slack/types' -import { getBotpressConversationFromSlackThread, getBotpressUserFromSlackUser } from 'src/misc/utils' -import { SlackClient } from 'src/slack-api' +import { + ActionsBlockElement, + AllMessageEvents, + ContextBlockElement, + FileShareMessageEvent, + GenericMessageEvent, + RichTextBlockElement, + RichTextElement, + RichTextSection, +} from '@slack/types' +import { textSchema } from 'definitions/channels/text-input-schema' +import { + getBotpressConversationFromSlackThread, + getBotpressUserFromSlackUser, + updateBotpressUserFromSlackUser, +} from 'src/misc/utils' import * as bp from '.botpress' -type BlocItem = bp.channels.channel.bloc.Bloc['items'][number] +type Mention = NonNullable['mentions']>[number] + +type BlocItem = + | bp.channels.channel.bloc.Bloc['items'][number] + | { + type: 'text' + payload: { + mentions: Mention[] + text: string + } + } + type MessageTag = keyof bp.ClientRequests['getOrCreateMessage']['tags'] export type HandleEventProps = { @@ -27,23 +52,7 @@ export const handleEvent = async (props: HandleEventProps) => { client ) const { botpressUser } = await getBotpressUserFromSlackUser({ slackUserId: slackEvent.user }, client) - - if (!botpressUser.pictureUrl || !botpressUser.name) { - try { - const slackClient = await SlackClient.createFromStates({ ctx, client, logger }) - const userProfile = await slackClient.getUserProfile({ userId: slackEvent.user }) - const fieldsToUpdate = { - pictureUrl: userProfile?.image_192, - name: userProfile?.real_name, - } - logger.forBot().debug('Fetched latest Slack user profile: ', fieldsToUpdate) - if (fieldsToUpdate.pictureUrl || fieldsToUpdate.name) { - await client.updateUser({ ...botpressUser, ...fieldsToUpdate }) - } - } catch (error) { - logger.forBot().error('Error while fetching user profile from Slack:', error) - } - } + await updateBotpressUserFromSlackUser(slackEvent.user, botpressUser, client, ctx, logger) const mentionsBot = await _isBotMentionedInMessage({ slackEvent, client, ctx }) const isSentInChannel = !slackEvent.thread_ts @@ -123,7 +132,6 @@ const _sendMessage = async (props: _SendMessageProps) => { if (slackEvent.subtype) { return } - const text = _parseSlackEventText(slackEvent) if (!text) { logger.forBot().debug('No text was received, so the message was ignored') @@ -132,7 +140,7 @@ const _sendMessage = async (props: _SendMessageProps) => { await client.getOrCreateMessage({ type: 'text', - payload: { text: slackToMarkdown(text) }, + payload: await _getTextPayloadFromSlackEvent(slackEvent, client, ctx, logger), userId: botpressUser.id, conversationId: botpressConversation.id, tags, @@ -187,6 +195,7 @@ const _getSlackBotIdFromStates = async (client: bp.Client, ctx: bp.Context) => { } const _getOrCreateMessageFromFiles = async ({ + ctx, botpressUser, botpressConversation, slackEvent, @@ -227,7 +236,7 @@ const _getOrCreateMessageFromFiles = async ({ const items: BlocItem[] = [] if (slackEvent.text) { - items.push({ type: 'text', payload: { text: slackToMarkdown(slackEvent.text) } }) + items.push({ type: 'text', payload: await _getTextPayloadFromSlackEvent(slackEvent, client, ctx, logger) }) } for (const file of parsedEvent.items) { @@ -254,18 +263,23 @@ const _parseSlackFile = (logger: bp.Logger, file: File): BlocItem | null => { return null } + if (!file.permalink_public) { + logger.forBot().info('File had no public permalink') + return null + } + switch (fileType) { case 'image': - return { type: fileType, payload: { imageUrl: file.permalink_public! } } + return { type: fileType, payload: { imageUrl: file.permalink_public } } case 'audio': - return { type: fileType, payload: { audioUrl: file.permalink_public! } } + return { type: fileType, payload: { audioUrl: file.permalink_public } } case 'file': - return { type: fileType, payload: { fileUrl: file.permalink_public! } } + return { type: fileType, payload: { fileUrl: file.permalink_public } } case 'text': - return { type: 'file', payload: { fileUrl: file.permalink_public! } } + return { type: 'file', payload: { fileUrl: file.permalink_public } } default: logger.forBot().info('File of type', fileType, 'is not yet supported.') @@ -311,3 +325,48 @@ const _parseFileSlackEvent = (slackEvent: FileShareMessageEvent): _ParsedFileSla return { type: 'bloc', text, items: slackEvent.files } } + +const _getTextPayloadFromSlackEvent = async ( + slackEvent: GenericMessageEvent | FileShareMessageEvent, + client: bp.Client, + ctx: bp.Context, + logger: bp.Logger +): Promise<{ + text: string + mentions: Mention[] +}> => { + if (!slackEvent.text) { + return { text: '', mentions: [] } + } + let text = slackEvent.text + const mentions: Mention[] = [] + const blocks = slackEvent.blocks ?? [] + + type BlockElement = ContextBlockElement | ActionsBlockElement | RichTextBlockElement + type BlockSubElement = RichTextSection | RichTextElement + const userElements = blocks + .flatMap((block): BlockElement[] => ('elements' in block ? block.elements : [])) + .flatMap((element): BlockSubElement[] => ('elements' in element ? element.elements : [])) + .filter((subElement) => subElement.type === 'user') + + for (const userElement of userElements) { + const { botpressUser } = await getBotpressUserFromSlackUser({ slackUserId: userElement.user_id }, client) + await updateBotpressUserFromSlackUser(userElement.user_id, botpressUser, client, ctx, logger) + if (!botpressUser.name) { + continue + } + text = text.replace(userElement.user_id, botpressUser.name) + mentions.push({ type: userElement.type, start: 1, end: 1, user: { id: botpressUser.id, name: botpressUser.name } }) + } + + for (const mention of mentions) { + if (!mention.user.name) { + continue + } + mention.start = text.search(mention.user.name) + mention.end = mention.start + mention.user.name.length + } + text = slackToMarkdown(text) + + return { text, mentions } +} diff --git a/packages/cli/e2e/tests/dev-bot.ts b/packages/cli/e2e/tests/dev-bot.ts index b48ea806fd6..c5109fc8d83 100644 --- a/packages/cli/e2e/tests/dev-bot.ts +++ b/packages/cli/e2e/tests/dev-bot.ts @@ -20,6 +20,7 @@ export const devBot: Test = { const botpressHomeDir = pathlib.join(tmpDir, '.botpresshome') const baseDir = pathlib.join(tmpDir, 'bots') const botName = uuid.v4() + const tunnelId = uuid.v4() const botDir = pathlib.join(baseDir, botName) const argv = { @@ -34,7 +35,7 @@ export const devBot: Test = { await utils.npmInstall({ workDir: botDir }).then(handleExitCode) await impl.login({ ...argv }).then(handleExitCode) - const cmdPromise = impl.dev({ ...argv, workDir: botDir, port: PORT, tunnelUrl }).then(handleExitCode) + const cmdPromise = impl.dev({ ...argv, workDir: botDir, port: PORT, tunnelUrl, tunnelId }).then(handleExitCode) await utils.sleep(5000) const allProcess = await findProcess('port', PORT) diff --git a/packages/cli/package.json b/packages/cli/package.json index 450e35fffc0..8d00a23ecaf 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@botpress/cli", - "version": "4.17.9", + "version": "4.17.10", "description": "Botpress CLI", "scripts": { "build": "pnpm run bundle && pnpm run template:gen", diff --git a/packages/cli/src/command-implementations/dev-command.ts b/packages/cli/src/command-implementations/dev-command.ts index 1307913716c..2db1da2b6b1 100644 --- a/packages/cli/src/command-implementations/dev-command.ts +++ b/packages/cli/src/command-implementations/dev-command.ts @@ -56,7 +56,20 @@ export class DevCommand extends ProjectCommand { throw new errors.BotpressCLIError(`Invalid tunnel URL: ${urlParseResult.error}`) } - const tunnelId = uuid.v4() + const cachedTunnelId = await this.projectCache.get('tunnelId') + + let tunnelId: string + if (this.argv.tunnelId) { + tunnelId = this.argv.tunnelId + } else if (cachedTunnelId) { + tunnelId = cachedTunnelId + } else { + tunnelId = uuid.v4() + } + + if (cachedTunnelId !== tunnelId) { + await this.projectCache.set('tunnelId', tunnelId) + } const { url: parsedTunnelUrl } = urlParseResult const isSecured = parsedTunnelUrl.protocol === 'https' || parsedTunnelUrl.protocol === 'wss' diff --git a/packages/cli/src/command-implementations/project-command.ts b/packages/cli/src/command-implementations/project-command.ts index 4975b517fcb..0a30e34e668 100644 --- a/packages/cli/src/command-implementations/project-command.ts +++ b/packages/cli/src/command-implementations/project-command.ts @@ -16,7 +16,7 @@ import * as utils from '../utils' import { GlobalCommand } from './global-command' export type ProjectCommandDefinition = CommandDefinition -export type ProjectCache = { botId: string; devId: string } +export type ProjectCache = { botId: string; devId: string; tunnelId: string } type ConfigurableProjectPaths = { workDir: string } type ConstantProjectPaths = typeof consts.fromWorkDir diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 433308ae327..54cf98a1eb9 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -217,6 +217,10 @@ const devSchema = { description: 'The tunnel HTTP URL to use', default: consts.defaultTunnelUrl, }, + tunnelId: { + type: 'string', + description: 'The tunnel ID to use. The ID will be generated if not specified', + }, } satisfies CommandSchema const addSchema = { diff --git a/packages/cli/src/errors.ts b/packages/cli/src/errors.ts index c3d48c6e488..ac79febb144 100644 --- a/packages/cli/src/errors.ts +++ b/packages/cli/src/errors.ts @@ -17,7 +17,13 @@ export class BotpressCLIError extends VError { return thrown } if (thrown instanceof client.UnknownError) { - const inst = new HTTPError(500, 'An unknown error has occurred.') + let inst: HTTPError + const cause = thrown.error?.cause + if (cause && typeof cause === 'object' && 'code' in cause && (cause as any).code === 'ECONNREFUSED') { + inst = new HTTPError(500, 'The connection was refused by the server') + } else { + inst = new HTTPError(500, 'An unknown error has occurred.') + } inst.debug = thrown.message return inst }