Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions integrations/slack/definitions/channels/text-input-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
41 changes: 41 additions & 0 deletions integrations/slack/hub.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion integrations/slack/integration.definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions integrations/slack/src/channels.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 },
Expand Down
33 changes: 33 additions & 0 deletions integrations/slack/src/misc/replace-mentions.test.ts
Original file line number Diff line number Diff line change
@@ -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>')
})
18 changes: 18 additions & 0 deletions integrations/slack/src/misc/replace-mentions.ts
Original file line number Diff line number Diff line change
@@ -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
}
28 changes: 28 additions & 0 deletions integrations/slack/src/misc/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { SlackClient } from 'src/slack-api'
import * as bp from '.botpress'

export const isValidUrl = (str: string) => {
Expand All @@ -20,6 +21,33 @@ export const getBotpressUserFromSlackUser = async (props: { slackUserId: string
}
}

export const updateBotpressUserFromSlackUser = async (
slackUserId: string,
botpressUser: Awaited<ReturnType<bp.Client['getOrCreateUser']>>['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
Expand Down
115 changes: 87 additions & 28 deletions integrations/slack/src/webhook-events/handlers/message-received.ts
Original file line number Diff line number Diff line change
@@ -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<z.infer<typeof textSchema>['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 = {
Expand All @@ -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
Expand Down Expand Up @@ -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')
Expand All @@ -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,
Expand Down Expand Up @@ -187,6 +195,7 @@ const _getSlackBotIdFromStates = async (client: bp.Client, ctx: bp.Context) => {
}

const _getOrCreateMessageFromFiles = async ({
ctx,
botpressUser,
botpressConversation,
slackEvent,
Expand Down Expand Up @@ -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) {
Expand All @@ -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.')
Expand Down Expand Up @@ -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 }
}
3 changes: 2 additions & 1 deletion packages/cli/e2e/tests/dev-bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading
Loading