chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
@@ -0,0 +1,21 @@
import { createActionWrapper } from '@botpress/common'
import { wrapAsyncFnWithTryCatch, SlackClient } from '../slack-api'
import * as bp from '.botpress'
export const wrapActionAndInjectSlackClient: typeof _wrapActionAndInjectTools = (meta, actionImpl) =>
_wrapActionAndInjectTools(meta, (props) =>
wrapAsyncFnWithTryCatch(() => {
props.logger.forBot().debug(`Running action "${meta.actionName}" [bot id: ${props.ctx.botId}]`)
return actionImpl(props as Parameters<typeof actionImpl>[0], props.input)
}, `Action Error: ${meta.errorMessage}`)()
)
const _wrapActionAndInjectTools = createActionWrapper<bp.IntegrationProps>()({
toolFactories: {
slackClient: (props) => SlackClient.createFromStates(props),
},
extraMetadata: {} as {
errorMessage: string
},
})
@@ -0,0 +1,25 @@
import * as sdk from '@botpress/sdk'
import { wrapActionAndInjectSlackClient } from 'src/actions/action-wrapper'
import { retrieveChannelAndMessageTs } from './utils/message-utils'
export const addReaction = wrapActionAndInjectSlackClient(
{ actionName: 'addReaction', errorMessage: 'Failed to add reaction' },
async ({ client, logger, slackClient }, { messageId, name }) => {
if (!messageId) {
throw new sdk.RuntimeError('Missing Botpress message ID')
}
const { channel, ts } = await retrieveChannelAndMessageTs({
client,
messageId,
})
logger.forBot().debug('Sending reaction to Slack')
await slackClient.addReactionToMessage({
channelId: channel,
messageTs: ts,
reactionName: name,
})
}
)
@@ -0,0 +1,55 @@
import Fuse from 'fuse.js'
import { wrapActionAndInjectSlackClient } from 'src/actions/action-wrapper'
import { Target } from '../../definitions/actions'
const fuse = new Fuse<Target>([], {
shouldSort: true,
threshold: 0.3,
minMatchCharLength: 2,
isCaseSensitive: false,
includeMatches: true,
findAllMatches: true,
useExtendedSearch: true,
ignoreLocation: true,
keys: ['displayName'],
})
export const findTarget = wrapActionAndInjectSlackClient(
{ actionName: 'findTarget', errorMessage: 'Failed to find any target' },
async ({ slackClient }, { channel, query }) => {
const targets: (Target & Record<string, unknown>)[] =
channel === 'dm'
? await slackClient
.enumerateAllMembers()
.collect()
.then((members) =>
members.map((member) => ({
// TODO: perform mapping in the slack client directly; we don't want
// to expose raw slack member objects outside of the custom
// slack client
displayName: member.name!,
name: member.profile?.real_name ?? '',
email: member.profile?.email ?? '',
tags: { id: member.id! },
channel: 'dm',
}))
)
: await slackClient
.enumerateAllPublicChannels()
.collect()
.then((channels) =>
channels.map((channel) => ({
displayName: channel.name!,
tags: { id: channel.id! },
channel: 'channel',
}))
)
fuse.setCollection(targets)
const filteredTargets: Target[] = fuse.search<Target>(query).map((x) => x.item)
return {
targets: filteredTargets,
}
}
)
@@ -0,0 +1,18 @@
import { wrapActionAndInjectSlackClient } from 'src/actions/action-wrapper'
export const getChannelsInfo = wrapActionAndInjectSlackClient(
{ actionName: 'getChannelsInfo', errorMessage: 'Failed to get channels info' },
async ({ slackClient }, { includeArchived, includePrivate, includeDm, cursor }) => {
const { channels, nextCursor } = await slackClient.getChannelsInfo({
includeArchived: includeArchived ?? false,
includePrivate: includePrivate ?? false,
includeDm: includeDm ?? false,
cursor,
})
return {
channels,
nextCursor: nextCursor ?? '',
}
}
)
@@ -0,0 +1,18 @@
import { RuntimeError } from '@botpress/client'
import { wrapActionAndInjectSlackClient } from 'src/actions/action-wrapper'
export const getOrCreateChannelConversation = wrapActionAndInjectSlackClient(
{ actionName: 'getOrCreateChannelConversation', errorMessage: 'Failed to get or create channel conversation' },
async ({ client }, { conversation: { channelId } }) => {
if (!channelId) {
throw new RuntimeError('channelId must be provided')
}
const { conversation } = await client.getOrCreateConversation({
channel: 'channel',
tags: { id: channelId },
})
return { conversationId: conversation.id }
}
)
@@ -0,0 +1,15 @@
import { wrapActionAndInjectSlackClient } from 'src/actions/action-wrapper'
export const getUserProfile = wrapActionAndInjectSlackClient(
{ actionName: 'getUserProfile', errorMessage: 'Failed to retrieve user profile' },
async ({ slackClient }, { userId }) => {
const userProfile = await slackClient.getUserProfile({ userId })
return {
firstName: userProfile?.first_name,
lastName: userProfile?.last_name,
email: userProfile?.email,
displayName: userProfile?.display_name,
}
}
)
+23
View File
@@ -0,0 +1,23 @@
import { addReaction } from './add-reaction'
import { findTarget } from './find-target'
import { getChannelsInfo } from './get-channels-info'
import { getOrCreateChannelConversation } from './get-or-create-channel-conversation'
import { getUserProfile } from './get-user-profile'
import { retrieveMessage } from './retrieve-message'
import { syncMembers } from './sync-members'
import { startTypingIndicator, stopTypingIndicator } from './typing-indicator'
import { updateChannelTopic } from './update-channel-topic'
import type * as bp from '.botpress'
export default {
addReaction,
findTarget,
getChannelsInfo,
getOrCreateChannelConversation,
retrieveMessage,
syncMembers,
updateChannelTopic,
startTypingIndicator,
stopTypingIndicator,
getUserProfile,
} satisfies bp.IntegrationProps['actions']
@@ -0,0 +1,20 @@
import { wrapActionAndInjectSlackClient } from 'src/actions/action-wrapper'
export const retrieveMessage = wrapActionAndInjectSlackClient(
{ actionName: 'retrieveMessage', errorMessage: 'Failed to retrieve message' },
async ({ logger, slackClient }, { ts, channel }) => {
const message = await slackClient.retrieveMessage({ channel, messageTs: ts })
if (!message.type || !message.ts || !message.user || !message.text) {
logger.forBot().error('Message is missing required fields')
throw new Error('Message is missing required fields')
}
return {
type: message.type,
ts: message.ts,
user: message.user,
text: message.text,
}
}
)
@@ -0,0 +1,99 @@
import { User } from '@botpress/client'
import { createOrUpdateUser } from '@botpress/common'
import { isApiError } from '@botpress/sdk'
import { Member } from '@slack/web-api/dist/response/UsersListResponse'
import { wrapActionAndInjectSlackClient } from 'src/actions/action-wrapper'
import * as bp from '.botpress'
export const syncMembers = wrapActionAndInjectSlackClient(
{ actionName: 'syncMembers', errorMessage: 'Failed to sync Slack users' },
async ({ logger, ctx, client, slackClient }, {}) => {
let { usersLastSyncTs } = await _getSyncState(client, ctx)
logger.forBot().debug(`Last sync timestamp for Slack users: ${usersLastSyncTs}`)
const unsortedMembers: Member[] = await slackClient.enumerateAllMembers().collect()
const recentlyUpdatedMembers = unsortedMembers
.filter((x) => (x.updated ?? 0) > (usersLastSyncTs ?? -1))
.sort((a, b) => (a.updated ?? 0) - (b.updated ?? 0))
logger
.forBot()
.debug(`Found ${recentlyUpdatedMembers.length}/${unsortedMembers.length} members to sync in Slack workspace`)
for (const slackMember of recentlyUpdatedMembers) {
logger
.forBot()
.debug('Syncing Slack user to Botpress...', { user: slackMember.name, updated: slackMember.updated })
try {
const user = await syncSlackUserToBotpressUser(slackMember, client)
logger.forBot().debug(`Synced Slack user ${user.name} (${user.id})`)
usersLastSyncTs = Math.max(usersLastSyncTs ?? 0, slackMember.updated ?? 0)
await _saveSyncState(client, ctx, { usersLastSyncTs })
} catch (err) {
logger.forBot().error(`Failed to sync Slack user ${slackMember.name}`, err)
}
}
logger.forBot().debug(`Synced ${recentlyUpdatedMembers.length} Slack users to Botpress`)
return { syncedCount: recentlyUpdatedMembers.length }
}
)
const syncSlackUserToBotpressUser = async (member: Member, botpressClient: bp.Client): Promise<User> => {
try {
const { user } = await createOrUpdateUser({
client: botpressClient,
name: member.name,
pictureUrl: member.profile?.image_512,
tags: {
id: member.id,
avatar_hash: member.profile?.avatar_hash,
display_name: member.profile?.display_name,
display_name_normalized: member.profile?.display_name_normalized,
email: member.profile?.email,
image_24: member.profile?.image_24,
image_48: member.profile?.image_48,
image_192: member.profile?.image_192,
image_512: member.profile?.image_512,
image_1024: member.profile?.image_1024,
is_admin: member.is_admin?.toString(),
is_bot: member.is_bot?.toString(),
phone: member.profile?.phone,
real_name: member.profile?.real_name,
real_name_normalized: member.profile?.real_name_normalized,
status_emoji: member.profile?.status_emoji,
status_text: member.profile?.status_text,
team: member.team_id,
title: member.profile?.title,
tz: member.tz,
},
discriminateByTags: ['id'],
})
return user
} catch (err) {
if (isApiError(err) && err.type === 'RateLimited') {
await new Promise((resolve) => setTimeout(resolve, 10 * 1000))
return syncSlackUserToBotpressUser(member, botpressClient)
}
throw err
}
}
type SyncState = { usersLastSyncTs?: number }
const _getSyncState = async (client: bp.Client, ctx: bp.Context): Promise<SyncState> => {
const {
state: { payload },
} = await client.getState({ type: 'integration', name: 'sync', id: ctx.integrationId }).catch(() => ({
state: { payload: {} as any },
}))
return payload as SyncState
}
const _saveSyncState = async (client: bp.Client, ctx: bp.Context, state: SyncState) => {
await client.setState({ type: 'integration', name: 'sync', id: ctx.integrationId, payload: state })
}
@@ -0,0 +1,57 @@
import { wrapActionAndInjectSlackClient } from './action-wrapper'
import { retrieveChannelAndMessageTs } from './utils/message-utils'
const TYPING_INDICATOR_EMOJI = 'eyes'
export const startTypingIndicator = wrapActionAndInjectSlackClient(
{ actionName: 'startTypingIndicator', errorMessage: 'Failed to start typing indicator' },
async ({ ctx, client, slackClient, logger }, { conversationId, messageId }) => {
const { channel, ts } = await retrieveChannelAndMessageTs({
client,
messageId,
})
if (ctx.configuration.typingIndicatorEmoji) {
logger
.forBot()
.debug(
`Adding reaction "${TYPING_INDICATOR_EMOJI}" to message ${messageId} in conversation ${conversationId} (typing indicator)`
)
await slackClient.addReactionToMessage({
channelId: channel,
messageTs: ts,
reactionName: TYPING_INDICATOR_EMOJI,
})
}
logger.forBot().debug(`Marking message ${messageId} as seen in conversation ${conversationId} (typing indicator)`)
await slackClient.markMessageAsSeen({
channelId: channel,
messageTs: ts,
})
}
)
export const stopTypingIndicator = wrapActionAndInjectSlackClient(
{ actionName: 'stopTypingIndicator', errorMessage: 'Failed to stop typing indicator' },
async ({ client, slackClient, logger, ctx }, { messageId, conversationId }) => {
const { channel, ts } = await retrieveChannelAndMessageTs({
client,
messageId,
})
if (ctx.configuration.typingIndicatorEmoji) {
logger
.forBot()
.debug(
`Removing reaction "${TYPING_INDICATOR_EMOJI}" from message ${messageId} in conversation ${conversationId} (typing indicator)`
)
await slackClient.removeReactionFromMessage({
channelId: channel,
messageTs: ts,
reactionName: TYPING_INDICATOR_EMOJI,
})
}
}
)
@@ -0,0 +1,8 @@
import { wrapActionAndInjectSlackClient } from './action-wrapper'
export const updateChannelTopic = wrapActionAndInjectSlackClient(
{ actionName: 'updateChannelTopic', errorMessage: 'Failed to update channel topic' },
async ({ slackClient }, { channelId, topic }) => {
await slackClient.setConversationTopic({ channelId, topic })
}
)
@@ -0,0 +1,18 @@
import * as sdk from '@botpress/client'
import * as bp from '.botpress'
export const retrieveChannelAndMessageTs = async ({ client, messageId }: { client: bp.Client; messageId: string }) => {
const { message } = await client.getMessage({ id: messageId })
const { conversation } = await client.getConversation({ id: message.conversationId })
const channel = conversation.tags.id
const ts = message.tags.ts
if (!channel) {
throw new sdk.RuntimeError('Channel ID is missing in conversation tags')
}
if (!ts) {
throw new sdk.RuntimeError('Timestamp is missing in message tags')
}
return { channel, ts }
}
+317
View File
@@ -0,0 +1,317 @@
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 { downloadBotpressFile, isValidUrl } from './misc/utils'
import { SlackClient } from './slack-api'
import { renderCard } from './slack-api/card-renderer'
import * as bp from '.botpress'
const defaultMessages = {
text: async ({ client, payload, ctx, conversation, ack, logger }) => {
const parsed = textSchema.parse(payload)
let transformedText = replaceMentions(parsed.text, parsed.mentions)
transformedText = transformMarkdownForSlack(transformedText)
parsed.text = transformedText
logger.forBot().debug('Sending text message to Slack chat:', payload)
await _sendSlackMessage(
{ ack, ctx, client, logger },
{
..._getSlackTarget(conversation),
...parsed,
}
)
},
image: async ({ client, payload, ctx, conversation, ack, logger }) => {
logger.forBot().debug('Sending image message to Slack chat:', payload)
await _sendSlackMessage(
{ ack, ctx, client, logger },
{
..._getSlackTarget(conversation),
blocks: [
{
type: 'image',
image_url: payload.imageUrl,
alt_text: 'image',
},
],
}
)
},
audio: async ({ ctx, conversation, ack, client, payload, logger }) => {
logger.forBot().debug('Sending audio message to Slack chat:', payload)
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 _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 _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}`
logger.forBot().debug('Sending location message to Slack chat:', payload)
await _sendSlackMessage(
{ ack, ctx, client, logger },
{
..._getSlackTarget(conversation),
text: 'location',
blocks: [
{
type: 'section',
text: { type: 'mrkdwn', text: `<${googleMapsLink}|location>` },
},
],
}
)
},
carousel: async ({ ctx, conversation, ack, client, payload, logger }) => {
logger.forBot().debug('Sending carousel message to Slack chat:', payload)
await _sendSlackMessage(
{ ack, ctx, client, logger },
{
..._getSlackTarget(conversation),
text: 'carousel',
blocks: payload.items.flatMap(renderCard).filter((value) => !!value),
}
)
},
card: async ({ ctx, conversation, ack, client, payload, logger }) => {
logger.forBot().debug('Sending card message to Slack chat:', payload)
await _sendSlackMessage(
{ ack, ctx, client, logger },
{
..._getSlackTarget(conversation),
text: 'card',
blocks: renderCard(payload),
}
)
},
dropdown: async ({ ctx, conversation, ack, client, payload, logger }) => {
logger.forBot().debug('Sending dropdown message to Slack chat:', payload)
await _sendSlackMessage(
{ ack, ctx, client, logger },
{
..._getSlackTarget(conversation),
text: payload.text,
blocks:
payload.options?.length > 0
? [
{
type: 'actions',
elements: [
{
type: 'static_select',
action_id: 'option_selected',
placeholder: {
type: 'plain_text',
text: payload.text,
},
options: payload.options
.filter((o) => o.label.length > 0)
.map((choice) => ({
text: {
type: 'plain_text',
text: choice.label,
},
value: choice.value,
})),
},
],
},
]
: undefined,
}
)
},
choice: async ({ ctx, conversation, ack, client, payload, logger }) => {
logger.forBot().debug('Sending choice message to Slack chat:', payload)
await _sendSlackMessage(
{ ack, ctx, client, logger },
{
..._getSlackTarget(conversation),
text: payload.text,
blocks:
payload.options?.length > 0
? [
{
type: 'section',
text: {
type: 'plain_text',
text: payload.text,
},
},
{
type: 'actions',
elements: payload.options
.filter((o) => o.label.length > 0)
.map((choice, i) => ({
type: 'button',
text: { type: 'plain_text', text: choice.label },
value: choice.value,
action_id: `quick_reply_${i}`,
})),
},
]
: undefined,
}
)
},
bloc: (props) => _sendBloc(props),
} satisfies bp.IntegrationProps['channels']['channel']['messages'] &
bp.IntegrationProps['channels']['dm']['messages'] &
bp.IntegrationProps['channels']['thread']['messages']
type SlackMessageProps<TMessage extends keyof bp.IntegrationProps['channels']['channel']['messages']> =
| Parameters<bp.IntegrationProps['channels']['channel']['messages'][TMessage]>[0]
| Parameters<bp.IntegrationProps['channels']['dm']['messages'][TMessage]>[0]
| Parameters<bp.IntegrationProps['channels']['thread']['messages'][TMessage]>[0]
const _sendBloc = async (props: SlackMessageProps<'bloc'>) => {
for (const item of props.payload.items) {
switch (item.type) {
case 'text':
await defaultMessages.text({ ...props, type: 'text', payload: item.payload })
break
case 'markdown':
// The bloc message uses the (deprecated) markdownBloc schema, so items may be markdown.
// Render them through the text handler, which already transforms markdown for Slack.
await defaultMessages.text({ ...props, type: 'text', payload: { text: item.payload.markdown } })
break
case 'image':
await defaultMessages.image({ ...props, type: 'image', payload: item.payload })
break
case 'audio':
await defaultMessages.audio({ ...props, type: 'audio', payload: item.payload })
break
case 'video':
await defaultMessages.video({ ...props, type: 'video', payload: item.payload })
break
case 'file':
await defaultMessages.file({ ...props, type: 'file', payload: item.payload })
break
case 'location':
await defaultMessages.location({ ...props, type: 'location', payload: item.payload })
break
default:
props.logger.forBot().warn(`Unsupported bloc item type: ${(item as { type: string }).type}`)
}
}
}
const _getSlackTarget = (conversation: bp.ClientResponses['getConversation']['conversation']) => {
const channel = conversation.tags.id
const thread = (conversation.tags as Record<string, string>).thread // TODO: fix cast in SDK typings
if (!channel) {
throw Error(`No channel found for conversation ${conversation.id}`)
}
return { channel, thread_ts: thread }
}
const _getOptionalProps = (ctx: bp.Context, logger: bp.Logger) => {
const props = {
username: ctx.configuration.botName?.trim(),
icon_url: undefined as string | undefined,
}
if (ctx.configuration.botAvatarUrl) {
if (isValidUrl(ctx.configuration.botAvatarUrl)) {
props.icon_url = ctx.configuration.botAvatarUrl
} else {
logger.forBot().warn('Invalid bot avatar URL')
}
}
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
) => {
const slackClient = await SlackClient.createFromStates({ client, ctx, logger })
const botOptionalProps = _getOptionalProps(ctx, logger)
const message = await slackClient.postMessage({
channelId: payload.channel,
threadTs: payload.thread_ts,
text: payload.text,
blocks: payload.blocks,
username: botOptionalProps.username,
iconUrl: botOptionalProps.icon_url,
})
if (!message) {
throw Error('Error sending message')
}
await ack({ tags: { ts: message.ts, channelId: payload.channel, userId: message?.user } })
return message
}
export default {
channel: { messages: defaultMessages },
dm: { messages: defaultMessages },
thread: { messages: defaultMessages },
} satisfies bp.IntegrationProps['channels']
+17
View File
@@ -0,0 +1,17 @@
import { reporting } from '@botpress/sdk-addons'
import actions from './actions'
import channels from './channels'
import { register, unregister } from './setup'
import { handler } from './webhook-events'
import * as bp from '.botpress'
const integration = new bp.Integration({
register,
unregister,
actions,
channels,
handler,
})
export default reporting.wrapIntegration(integration)
@@ -0,0 +1,56 @@
import { transformMarkdown, MarkdownHandlers, stripAllHandlers } from '@botpress/common'
/** 'En space' yields better results for indentation */
const FIXED_SIZE_SPACE_CHAR = '\u2002'
/**
* Slack mrkdwn format handlers
* @see https://docs.slack.dev/messaging/formatting-message-text/
*/
const slackHandlers: MarkdownHandlers = {
...stripAllHandlers,
emphasis: (node, visit) => `_${visit(node)}_`,
delete: (node, visit) => `~${visit(node)}~`,
strong: (node, visit) => `*${visit(node)}*`,
break: () => '\n',
blockquote: (node, visit) => {
const content = visit(node).trim()
return (
content
.split('\n')
.map((line) => `>${line}`)
.join('\n') + '\n'
)
},
inlineCode: (node) => `\`${node.value}\``,
code: (node) => `\`\`\`\n${node.value}\n\`\`\`\n`,
list: (node, visit) => {
return `${node.listLevel !== 1 ? '\n' : ''}${visit(node)}`
},
listItem: (node, visit) => {
const { itemCount, checked, ownerList } = node
let prefix = FIXED_SIZE_SPACE_CHAR.repeat((ownerList.listLevel - 1) * 2)
if (checked !== null) {
prefix += checked ? '☑︎ ' : '☐ '
} else {
prefix += ownerList.ordered === true ? `${itemCount}. ` : '- '
}
const shouldBreak = ownerList.listLevel === 1 || itemCount < ownerList.children.length
return `${prefix}${visit(node)}${shouldBreak ? '\n' : ''}`
},
link: (node, visit) => {
const text = visit(node)
return text ? `<${node.url}|${text}>` : `<${node.url}>`
},
paragraph: (node, visit) => `${visit(node)}\n`,
html: (node) => {
const SLACK_SPECIAL_MENTIONS = ['<!here>', '<!everyone>', '<!channel>']
return SLACK_SPECIAL_MENTIONS.includes(node.value) ? node.value : ''
},
}
export function transformMarkdownForSlack(text: string): string {
return transformMarkdown(text, slackHandlers)
}
@@ -0,0 +1,29 @@
import { test, expect, vi } from 'vitest'
import { replaceMentions, Mention } from './replace-mentions'
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>')
})
@@ -0,0 +1,18 @@
export type Mention = {
start: number
end: number
user: { id: string; name: string }
}
export const replaceMentions = (text: string, mentions: Mention[] | undefined): string => {
if (!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
}
+164
View File
@@ -0,0 +1,164 @@
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<ArrayBuffer>(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)
return true
} catch {
return false
}
}
export const getBotpressUserFromSlackUser = async (props: { slackUserId: string }, client: bp.Client) => {
const { user } = await client.getOrCreateUser({
tags: { id: props.slackUserId },
})
return {
botpressUser: user,
botpressUserId: user.id,
}
}
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
) => {
let conversation: bp.ClientResponses['getConversation']['conversation']
if (props.slackThreadId) {
const resp = await client.getOrCreateConversation({
channel: 'thread',
tags: { id: props.slackChannelId, thread: props.slackThreadId },
})
conversation = resp.conversation
} else {
const channel = props.slackChannelId.startsWith('D') ? 'dm' : 'channel'
const resp = await client.getOrCreateConversation({
channel,
tags: { id: props.slackChannelId },
})
conversation = resp.conversation
}
return {
botpressConversation: conversation,
botpressConversationId: conversation.id,
}
}
export const getMessageFromSlackEvent = async (
client: bp.Client,
event: { item: { type: string; channel?: string; ts?: string } }
) => {
if (event.item.type !== 'message' || !event.item.channel || !event.item.ts) {
return undefined
}
const { messages } = await client.listMessages({
tags: { ts: event.item.ts, channelId: event.item.channel },
})
return messages[0]
}
export const safeParseBody = (body: string) => {
const parseResult = _safeParseJson(body)
return parseResult.success ? parseResult : _safeDecodeURIComponent(body)
}
const _safeDecodeURIComponent = (component: string) => {
try {
const decoded = decodeURIComponent(component)
return _safeParseJson(decoded.startsWith('payload=') ? decoded.slice('payload='.length) : decoded)
} catch (thrown: unknown) {
return {
success: false,
error: thrown instanceof Error ? thrown : new Error(String(thrown)),
}
}
}
const _safeParseJson = (json: string) => {
try {
return {
success: true,
data: JSON.parse(json),
}
} catch (thrown: unknown) {
return {
success: false,
error: thrown instanceof Error ? thrown : new Error(String(thrown)),
}
}
}
@@ -0,0 +1,23 @@
import { isOAuthWizardUrl, getInterstitialUrl, generateRedirection } from '@botpress/common/src/oauth-wizard'
import * as wizard from './wizard'
import * as bp from '.botpress'
export const oauthWizardHandler: bp.IntegrationProps['handler'] = async (props) => {
const { req, logger } = props
if (!isOAuthWizardUrl(req.path)) {
return {
status: 404,
body: 'Invalid OAuth wizard endpoint',
}
}
try {
return await wizard.handler(props)
} catch (thrown: unknown) {
const error = thrown instanceof Error ? thrown : Error(String(thrown))
const errorMessage = 'OAuth wizard error: ' + error.message
logger.forBot().error(errorMessage)
return generateRedirection(getInterstitialUrl(false, errorMessage))
}
}
@@ -0,0 +1,186 @@
import * as oauthWizard from '@botpress/common/src/oauth-wizard'
import { Response, z } from '@botpress/sdk'
import { REQUIRED_SLACK_SCOPES } from '../setup'
import { SlackManifestClient, buildSlackAppManifest, patchAppCredentials } from '../slack-api/slack-manifest-client'
import { handleOAuthCallback } from '../webhook-events/handlers/oauth-callback'
import * as bp from '.botpress'
type WizardHandler = oauthWizard.WizardStepHandler<bp.HandlerProps>
const _buildSlackAuthorizeUrl = (clientId: string, webhookId: string): string => {
const redirectUri = `${process.env.BP_WEBHOOK_URL}/oauth`
const scopes = REQUIRED_SLACK_SCOPES.join(',')
return `https://slack.com/oauth/v2/authorize?client_id=${encodeURIComponent(clientId)}&scope=${encodeURIComponent(scopes)}&redirect_uri=${encodeURIComponent(redirectUri)}&state=${encodeURIComponent(webhookId)}`
}
const _manualCredentialsSchema = z.object({
clientId: z.string().title('Slack Client ID').describe('Available in the app admin panel under Basic Info'),
clientSecret: z
.string()
.secret()
.title('Slack Client Secret')
.describe('Available in the app admin panel under Basic Info'),
signingSecret: z
.string()
.secret()
.title('Slack Signing Secret')
.describe('Available in the app admin panel under Basic Info'),
})
const _manifestConfigSchema = z.object({
appConfigurationRefreshToken: z
.string()
.secret()
.title('Slack App Configuration Refresh Token')
.describe('Generated from api.slack.com/apps'),
appName: z
.string()
.max(35, 'App name must be 35 characters or less')
.title('Slack App Name')
.describe('Choose a name for the Slack app (max 35 characters)'),
})
const _manualCredentialsForm = {
pageTitle: 'Slack App Credentials',
htmlOrMarkdownPageContents:
'Enter your Slack app credentials.<br>' +
'You can find these in the <a href="https://api.slack.com/apps" target="_blank">app admin panel</a> under each application\'s <strong>Basic Information</strong>.',
schema: _manualCredentialsSchema,
nextStepId: 'save-manual-credentials',
}
const _manifestConfigForm = {
pageTitle: 'Slack App Configuration',
htmlOrMarkdownPageContents:
'Generate an App Configuration in the <a href="https://api.slack.com/apps" target="_blank">app admin panel</a>, near the bottom of the page.<br>' +
'Enter your Slack App Configuration Refresh Token, and a name for your app.',
schema: _manifestConfigSchema,
nextStepId: 'create-app',
}
export const handler = async (props: bp.HandlerProps): Promise<Response> => {
const wizard = new oauthWizard.OAuthWizardBuilder(props)
.addStep({ id: 'start', handler: _startHandler })
.addStep({ id: 'route-choice', handler: _routeChoiceHandler })
.addStep({ id: 'oauth-redirect', handler: _oauthRedirectHandler })
.addStep({ id: 'get-manual-credentials', handler: _getManualCredentialsHandler })
.addStep({ id: 'save-manual-credentials', handler: _saveManualCredentialsHandler })
.addStep({ id: 'get-manifest-config', handler: _getManifestConfigHandler })
.addStep({ id: 'create-app', handler: _createAppHandler })
.addStep({ id: 'oauth-callback', handler: _oauthCallbackHandler })
.addStep({ id: 'end', handler: _endHandler })
.build()
return await wizard.handleRequest()
}
const _startHandler: WizardHandler = ({ responses }) => {
return responses.displayChoices({
pageTitle: 'Slack Integration Setup',
htmlOrMarkdownPageContents: 'Choose how you would like to configure your Slack integration:',
choices: [
{ label: 'Connect with OAuth', value: 'default' },
{ label: 'Configure a new Slack Application', value: 'manifest' },
{ label: 'Use existing Slack Application Credentials', value: 'manual' },
],
nextStepId: 'route-choice',
})
}
const _routeChoiceHandler: WizardHandler = ({ selectedChoice, responses }) => {
switch (selectedChoice) {
case 'manual':
return responses.redirectToStep('get-manual-credentials')
case 'manifest':
return responses.redirectToStep('get-manifest-config')
case 'default':
default:
return responses.redirectToStep('oauth-redirect')
}
}
const _oauthRedirectHandler: WizardHandler = ({ ctx, responses }) => {
return responses.redirectToExternalUrl(_buildSlackAuthorizeUrl(bp.secrets.CLIENT_ID, ctx.webhookId))
}
const _getManualCredentialsHandler: WizardHandler = ({ responses }) => {
return responses.displayForm(_manualCredentialsForm)
}
const _saveManualCredentialsHandler: WizardHandler = async ({ ctx, client, responses, formValues }) => {
if (!formValues) {
return responses.redirectToStep('get-manual-credentials')
}
const parsed = _manualCredentialsSchema.safeParse(formValues)
if (!parsed.success) {
return responses.displayForm({
..._manualCredentialsForm,
errors: parsed.error,
previousValues: formValues as z.input<typeof _manualCredentialsSchema>,
})
}
const { signingSecret, clientId, clientSecret } = parsed.data
await patchAppCredentials(client, ctx, { signingSecret, clientId, clientSecret })
return responses.redirectToExternalUrl(_buildSlackAuthorizeUrl(clientId, ctx.webhookId))
}
const _getManifestConfigHandler: WizardHandler = ({ responses }) => {
return responses.displayForm(_manifestConfigForm)
}
const _createAppHandler: WizardHandler = async (props) => {
const { client, ctx, responses, logger, formValues } = props
if (!formValues) {
return responses.redirectToStep('get-manifest-config')
}
const parsed = _manifestConfigSchema.safeParse(formValues)
if (!parsed.success) {
return responses.displayForm({
..._manifestConfigForm,
errors: parsed.error,
previousValues: formValues as z.input<typeof _manifestConfigSchema>,
})
}
const { appConfigurationRefreshToken, appName } = parsed.data
await patchAppCredentials(client, ctx, { appConfigurationRefreshToken })
const webhookUrl = `${process.env.BP_WEBHOOK_URL!}/${ctx.webhookId}`
const redirectUri = `${process.env.BP_WEBHOOK_URL!}/oauth`
const manifest = buildSlackAppManifest(webhookUrl, redirectUri, appName)
const manifestClient = await SlackManifestClient.create({ client, ctx, logger })
logger.forBot().debug('Validating Slack app manifest...')
await manifestClient.validateManifest(manifest)
logger.forBot().debug('Creating Slack app from manifest...')
const { credentials, oauth_authorize_url } = await manifestClient.createApp(manifest)
await patchAppCredentials(client, ctx, {
clientId: credentials.client_id,
clientSecret: credentials.client_secret,
signingSecret: credentials.signing_secret,
})
const authorizeUrl = new URL(oauth_authorize_url)
authorizeUrl.searchParams.set('redirect_uri', redirectUri)
authorizeUrl.searchParams.set('state', ctx.webhookId)
return responses.redirectToExternalUrl(authorizeUrl.toString())
}
const _oauthCallbackHandler: WizardHandler = async ({ req, client, ctx, logger, responses }) => {
await handleOAuthCallback({ req, client, ctx, logger })
return responses.redirectToStep('end')
}
const _endHandler: WizardHandler = ({ responses }) => {
return responses.endWizard({ success: true })
}
+70
View File
@@ -0,0 +1,70 @@
import { RuntimeError } from '@botpress/sdk'
import { isValidUrl } from './misc/utils'
import { SlackClient } from './slack-api'
import type * as bp from '.botpress'
export const REQUIRED_SLACK_SCOPES = [
'channels:history',
'channels:manage',
'channels:read',
'chat:write',
'files:read',
'files:write',
'groups:history',
'groups:read',
'groups:write',
'im:history',
'im:read',
'im:write',
'mpim:history',
'mpim:read',
'mpim:write',
'reactions:read',
'reactions:write',
'team:read',
'users.profile:read',
'users:read',
'users:read.email',
]
export const register: bp.IntegrationProps['register'] = async ({ client, ctx, logger }) => {
logger.forBot().debug('Registering Slack integration')
await _updateBotpressBotNameAndAvatar({ client, ctx, logger })
const slackClient = await SlackClient.createFromStates({ client, ctx, logger })
await slackClient.testAuthentication()
const hasRequiredScopes = slackClient.hasAllScopes(REQUIRED_SLACK_SCOPES)
if (!hasRequiredScopes) {
const grantedScopes = slackClient.getGrantedScopes()
const missingScopes = REQUIRED_SLACK_SCOPES.filter((scope) => !grantedScopes.includes(scope))
throw new RuntimeError(
'The Slack access token is missing required scopes. Please re-authorize the app.\n\n' +
`Missing scopes: ${missingScopes.join(', ')}.\n` +
`Granted scopes: ${grantedScopes.join(', ')}.`
)
}
}
const _updateBotpressBotNameAndAvatar = async ({ client, ctx, logger }: bp.CommonHandlerProps) => {
const { botAvatarUrl } = ctx.configuration
const isUrlValid = botAvatarUrl && isValidUrl(botAvatarUrl)
if (!isUrlValid) {
logger.forBot().warn('The provided bot avatar URL is invalid. Skipping avatar picture update.')
}
await client.updateUser({
id: ctx.botUserId,
pictureUrl: isUrlValid ? botAvatarUrl.trim() : undefined,
name: ctx.configuration.botName?.trim(),
})
}
export const unregister: bp.IntegrationProps['unregister'] = async () => {
// nothing to unregister
}
@@ -0,0 +1,67 @@
import type { ChatPostMessageArguments } from '@slack/web-api'
import { channels } from '.botpress'
type Card = channels.channel.card.Card
type CardAction = channels.channel.card.Card['actions'][number]
export const renderCard = (payload: Card): ChatPostMessageArguments['blocks'] => [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*${payload.title}*\n${payload.subtitle}`,
},
accessory: payload.imageUrl
? {
type: 'image',
image_url: payload.imageUrl,
alt_text: 'image',
}
: undefined,
},
{
type: 'actions',
elements: payload.actions.map((item) => {
switch (item.action) {
case 'say':
return _renderButtonSay(item)
case 'postback':
return _renderButtonPostback(item)
case 'url':
return _renderButtonUrl(item)
default:
item.action satisfies never
throw Error(`Unknown action type ${item.action}`)
}
}),
},
]
const _renderButtonUrl = (action: CardAction) => ({
type: 'button' as const,
text: {
type: 'plain_text' as const,
text: action.label,
},
url: action.value,
})
const _renderButtonPostback = (action: CardAction) => ({
type: 'button' as const,
action_id: 'postback',
text: {
type: 'plain_text' as const,
text: action.label,
},
value: action.value,
})
const _renderButtonSay = (action: CardAction) => ({
type: 'button' as const,
action_id: 'say',
text: {
type: 'plain_text' as const,
text: action.label,
},
value: action.value,
})
@@ -0,0 +1,71 @@
import { createAsyncFnWrapperWithErrorRedaction, createErrorHandlingDecorator } from '@botpress/common'
import * as sdk from '@botpress/sdk'
import * as slackWebApi from '@slack/web-api'
import { Logger as BPLogger } from '.botpress'
export const redactSlackError = (thrown: unknown, genericErrorMessage: string): sdk.RuntimeError => {
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
let errorMessage = genericErrorMessage
console.error('Slack error', { error, genericErrorMessage })
if (error instanceof sdk.RuntimeError) {
return error
}
if ('code' in error) {
switch (error.code) {
case slackWebApi.ErrorCode.RequestError:
case slackWebApi.ErrorCode.HTTPError:
errorMessage +=
': an HTTP error occurred whilst communicating with Slack. Please report this error to Botpress.'
break
case slackWebApi.ErrorCode.PlatformError:
if ('data' in error && 'error' in (error.data as {})) {
switch ((error.data as { error: string }).error) {
case 'token_rotation_not_enabled':
errorMessage +=
': Token rotation is not enabled. Please check your Slack app OAuth settings and opt in to token rotation.'
break
default:
errorMessage += `: ${error.message}.\n\nError details:\n${JSON.stringify(error.data)}`
}
}
break
case slackWebApi.ErrorCode.RateLimitedError:
errorMessage += ': Slack rate limited the request. Please try again later.'
break
default:
console.warn(`Unhandled Slack error code: ${error.code}`, error)
break
}
}
return new sdk.RuntimeError(errorMessage)
}
export const wrapAsyncFnWithTryCatch = createAsyncFnWrapperWithErrorRedaction(redactSlackError)
export const handleErrorsDecorator = createErrorHandlingDecorator(wrapAsyncFnWithTryCatch)
export const surfaceSlackErrors = <TResponse extends slackWebApi.WebAPICallResult>({
logger,
response,
}: {
logger: BPLogger
response: TResponse
}): TResponse => {
if (response.response_metadata?.warnings?.length) {
logger.forBot().warn('Slack API emitted warnings', response.response_metadata.warnings)
}
if (response.error) {
throw new sdk.RuntimeError(`Slack API error: ${response.error}`)
}
if (!response.ok) {
throw new sdk.RuntimeError('Slack API returned an unspecified error')
}
return response
}
@@ -0,0 +1,3 @@
export { SlackClient } from './slack-client'
export { SlackManifestClient, buildSlackAppManifest } from './slack-manifest-client'
export { wrapAsyncFnWithTryCatch } from './error-handling'
@@ -0,0 +1,435 @@
import { collectableGenerator } from '@botpress/common'
import * as sdk from '@botpress/sdk'
import * as SlackWebApi from '@slack/web-api'
import { handleErrorsDecorator as handleErrors, surfaceSlackErrors } from './error-handling'
import { getAppCredentials } from './slack-manifest-client'
import { SlackOAuthClient } from './slack-oauth-client'
import { requiresAllScopesDecorator as requireAllScopes } from './slack-scopes'
import * as bp from '.botpress'
export class SlackClient {
private readonly _logger: bp.Logger
private readonly _slackWebClient: SlackWebApi.WebClient
private readonly _grantedScopes: string[]
private readonly _botUserId: string
private readonly _teamId: string
private constructor(props: {
logger: bp.Logger
slackWebClient: SlackWebApi.WebClient
grantedScopes: string[]
botUserId: string
teamId: string
}) {
this._logger = props.logger
this._slackWebClient = props.slackWebClient
this._grantedScopes = props.grantedScopes
this._botUserId = props.botUserId
this._teamId = props.teamId
}
public static async createFromStates({
ctx,
client,
logger,
}: {
client: bp.Client
ctx: bp.Context
logger: bp.Logger
}) {
const appCreds = await getAppCredentials(client, ctx)
if (appCreds.clientId && appCreds.clientSecret) {
const oAuthClient = new SlackOAuthClient({
ctx,
client,
logger,
clientIdOverride: appCreds.clientId,
clientSecretOverride: appCreds.clientSecret,
})
return await SlackClient._createNewInstance({ logger, oAuthClient })
}
const oAuthClient = new SlackOAuthClient({ ctx, client, logger })
return await SlackClient._createNewInstance({ logger, oAuthClient })
}
public static async createFromAuthorizationCode({
ctx,
client,
logger,
authorizationCode,
}: {
client: bp.Client
ctx: bp.Context
logger: bp.Logger
authorizationCode: string
}) {
const appCreds = await getAppCredentials(client, ctx)
const redirectUri = `${process.env.BP_WEBHOOK_URL}/oauth`
if (appCreds.clientId && appCreds.clientSecret) {
const oAuthClient = new SlackOAuthClient({
ctx,
client,
logger,
clientIdOverride: appCreds.clientId,
clientSecretOverride: appCreds.clientSecret,
})
await oAuthClient.requestShortLivedCredentials.fromAuthorizationCode(authorizationCode, redirectUri)
return await SlackClient._createNewInstance({ logger, oAuthClient })
}
const oAuthClient = new SlackOAuthClient({ ctx, client, logger })
await oAuthClient.requestShortLivedCredentials.fromAuthorizationCode(authorizationCode)
return await SlackClient._createNewInstance({ logger, oAuthClient })
}
private static async _createNewInstance({
logger,
oAuthClient,
}: {
logger: bp.Logger
oAuthClient: SlackOAuthClient
}) {
const { accessToken, scopes, botUserId, teamId } = await oAuthClient.getAuthState()
const slackWebClient = new SlackWebApi.WebClient(accessToken)
return new SlackClient({ logger, slackWebClient, grantedScopes: scopes, botUserId, teamId })
}
@handleErrors('Failed to validate Slack authentication')
public async testAuthentication() {
surfaceSlackErrors({
logger: this._logger,
response: await this._slackWebClient.auth.test(),
})
}
public getBotUserId(): string {
return this._botUserId
}
public getTeamId(): string {
return this._teamId
}
public getGrantedScopes(): Readonly<string[]> {
return this._grantedScopes
}
public hasAllScopes(requiredScopes: string[]): boolean {
return requiredScopes.every((scope) => this._grantedScopes.includes(scope))
}
@requireAllScopes(['reactions:write'])
@handleErrors('Failed to add reaction to message')
public async addReactionToMessage({
channelId,
messageTs,
reactionName,
}: {
channelId: string
messageTs: string
reactionName: string
}) {
await this._slackWebClient.reactions.add({
channel: channelId,
timestamp: messageTs,
name: reactionName,
})
}
@requireAllScopes(['reactions:write'])
@handleErrors('Failed to remove reaction from message')
public async removeReactionFromMessage({
channelId,
messageTs,
reactionName,
}: {
channelId: string
messageTs: string
reactionName: string
}) {
await this._slackWebClient.reactions.remove({
channel: channelId,
timestamp: messageTs,
name: reactionName,
})
}
public enumerateAllMembers = collectableGenerator(this._enumerateAllMembers.bind(this))
@requireAllScopes(['users:read'])
@handleErrors('Failed to enumerate Slack users')
private async *_enumerateAllMembers({ limit }: { limit?: number } = {}) {
let cursor: string | undefined
let resultCount = 0
do {
const { members, response_metadata } = surfaceSlackErrors({
logger: this._logger,
response: await this._slackWebClient.users.list({
cursor,
limit: 200,
}),
})
for (const member of members ?? []) {
if (limit && resultCount++ > limit) {
return
} else if (member.deleted) {
continue
}
yield member
}
cursor = response_metadata?.next_cursor
} while ((!limit || resultCount < limit) && cursor)
}
public enumerateAllPublicChannels = collectableGenerator(this._enumerateAllPublicChannels.bind(this))
@requireAllScopes(['channels:read'])
@handleErrors('Failed to enumerate public Slack channels')
private async *_enumerateAllPublicChannels({ limit }: { limit?: number } = {}) {
let cursor: string | undefined
let resultCount = 0
do {
const { channels, response_metadata } = surfaceSlackErrors({
logger: this._logger,
response: await this._slackWebClient.conversations.list({
exclude_archived: true,
cursor,
limit: 200,
types: 'public_channel',
}),
})
for (const channel of channels ?? []) {
if (limit && resultCount++ > limit) {
return
}
yield channel
}
cursor = response_metadata?.next_cursor
} while ((!limit || resultCount < limit) && cursor)
}
@requireAllScopes(['channels:history', 'groups:history', 'im:history', 'mpim:history'])
@handleErrors('Failed to retrieve message')
public async retrieveMessage({ channel, messageTs }: { channel: string; messageTs: string }) {
const { messages } = surfaceSlackErrors({
logger: this._logger,
response: await this._slackWebClient.conversations.history({
channel,
limit: 1,
inclusive: true,
latest: messageTs,
}),
})
const message = messages?.[0]
if (!message) {
throw new sdk.RuntimeError('No message found')
}
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) {
surfaceSlackErrors({
logger: this._logger,
response: await this._slackWebClient.conversations.open({
channel: channelId,
}),
})
return channelId
}
@requireAllScopes(['channels:manage', 'groups:write', 'im:write', 'mpim:write'])
@handleErrors('Failed to mark message as seen')
public async markMessageAsSeen({ channelId, messageTs }: { channelId: string; messageTs: string }) {
await this._slackWebClient.conversations.mark({
channel: channelId,
ts: messageTs,
})
}
@requireAllScopes(['channels:manage', 'groups:write', 'mpim:write', 'im:write'])
@handleErrors('Failed to set conversation topic')
public async setConversationTopic({ channelId, topic }: { channelId: string; topic: string }) {
await this._slackWebClient.conversations.setTopic({
channel: channelId,
topic,
})
}
@requireAllScopes(['chat:write'])
@handleErrors('Failed to post message')
public async postMessage({
channelId,
text,
threadTs,
blocks,
username,
iconUrl,
}: {
channelId: string
text?: string
threadTs?: string
blocks?: (SlackWebApi.Block | SlackWebApi.KnownBlock)[]
username?: string
iconUrl?: string
}) {
const response = surfaceSlackErrors({
logger: this._logger,
response: await this._slackWebClient.chat.postMessage({
channel: channelId,
text,
thread_ts: threadTs,
blocks,
as_user: true,
username,
icon_url: iconUrl,
}),
})
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 }) {
const response = surfaceSlackErrors({
logger: this._logger,
response: await this._slackWebClient.users.profile.get({
user: userId,
}),
})
return response.profile
}
@requireAllScopes(['channels:read', 'chat:write', 'groups:read', 'im:read', 'mpim:read'])
@handleErrors('Failed to retrieve channels info')
public async getChannelsInfo({
includeArchived,
includePrivate,
includeDm,
cursor,
}: {
includeArchived?: boolean
includePrivate?: boolean
includeDm?: boolean
cursor?: string
}) {
const types = ['public_channel']
if (includePrivate) {
types.push('private_channel')
}
if (includeDm) {
types.push('im', 'mpim')
}
const response = surfaceSlackErrors({
logger: this._logger,
response: await this._slackWebClient.conversations.list({
types: types.join(','),
exclude_archived: !includeArchived,
limit: 200,
cursor,
}),
})
return {
channels: (response.channels ?? []).map((ch) => ({
id: ch.id ?? '',
name: ch.name ?? ch.user ?? ch.id ?? '',
topic: ch.topic?.value ?? '',
purpose: ch.purpose?.value ?? '',
numMembers: ch.num_members ?? 0,
isPrivate: ch.is_private ?? false,
isArchived: ch.is_archived ?? false,
isDm: ch.is_im || ch.is_mpim || false,
userId: ch.user ?? '',
creator: ch.creator ?? '',
created: ch.created ?? 0,
})),
nextCursor: response.response_metadata?.next_cursor || undefined,
}
}
}
@@ -0,0 +1,247 @@
import { z, RuntimeError } from '@botpress/sdk'
import * as SlackWebClient from '@slack/web-api'
import { states } from 'definitions'
import { REQUIRED_SLACK_SCOPES } from '../setup'
import { surfaceSlackErrors } from './error-handling'
import * as bp from '.botpress'
const BOT_EVENTS = [
'message.im',
'message.groups',
'message.mpim',
'message.channels',
'reaction_added',
'reaction_removed',
'member_joined_channel',
'member_left_channel',
'team_join',
]
const manifestSchema = z.object({
display_information: z.object({
name: z.string(),
}),
features: z.object({
bot_user: z.object({
display_name: z.string(),
always_online: z.boolean(),
}),
}),
oauth_config: z.object({
scopes: z.object({
bot: z.array(z.string()),
}),
redirect_urls: z.array(z.string()),
token_management_enabled: z.boolean(),
}),
settings: z.object({
event_subscriptions: z.object({
request_url: z.string(),
bot_events: z.array(z.string()),
}),
interactivity: z.object({
is_enabled: z.boolean(),
request_url: z.string(),
}),
org_deploy_enabled: z.boolean(),
socket_mode_enabled: z.boolean(),
token_rotation_enabled: z.boolean(),
}),
})
const manifestCreateResponseSchema = z.object({
ok: z.literal(true),
app_id: z.string(),
credentials: z.object({
client_id: z.string(),
client_secret: z.string(),
signing_secret: z.string(),
}),
oauth_authorize_url: z.string(),
})
export type ManifestCreateResponse = z.infer<typeof manifestCreateResponseSchema>
export type SlackAppManifest = z.infer<typeof manifestSchema>
type AppCredentialsState = bp.states.appCredentials.AppCredentials['payload']
export const patchAppCredentials = async (
client: bp.Client,
ctx: bp.Context,
newState: Partial<AppCredentialsState>
) => {
const state = await getAppCredentials(client, ctx)
const { data, success } = states.appCredentials.schema.safeParse({
...state,
...newState,
})
if (!success) {
throw new RuntimeError(`Failed to parse app credentials state: ${JSON.stringify(data)}`)
}
await client.setState({
type: 'integration',
id: ctx.integrationId,
name: 'appCredentials',
payload: data,
})
}
export const getAppCredentials = async (client: bp.Client, ctx: bp.Context): Promise<Partial<AppCredentialsState>> => {
try {
const { state } = await client.getState({
type: 'integration',
name: 'appCredentials',
id: ctx.integrationId,
})
return state.payload
} catch {
return {}
}
}
export class SlackManifestClient {
private readonly _logger: bp.Logger
private readonly _appConfigurationToken: string
private readonly _slackWebClient: SlackWebClient.WebClient
private constructor(appConfigurationToken: string, logger: bp.Logger) {
this._logger = logger
this._appConfigurationToken = appConfigurationToken
this._slackWebClient = new SlackWebClient.WebClient(this._appConfigurationToken)
}
public static async create(props: bp.CommonHandlerProps) {
const validToken = await SlackManifestClient._resolveAndValidateToken(props)
return new SlackManifestClient(validToken, props.logger)
}
private static async _resolveTokens({
client,
ctx,
}: bp.CommonHandlerProps): Promise<{ appConfigurationRefreshToken: string }> {
const state = await getAppCredentials(client, ctx)
const appConfigurationRefreshToken = state.appConfigurationRefreshToken
if (!appConfigurationRefreshToken) {
throw new RuntimeError('Slack manifest app credentials are not properly configured')
}
return { appConfigurationRefreshToken }
}
private static async _resolveAndValidateToken(props: bp.CommonHandlerProps): Promise<string> {
const { client, ctx, logger } = props
const { appConfigurationRefreshToken } = await SlackManifestClient._resolveTokens(props)
const slackWebClient = new SlackWebClient.WebClient()
logger.forBot().debug('Rotating Slack app configuration token using refresh token...')
const { token: rotatedToken, refresh_token } = surfaceSlackErrors({
logger,
response: await slackWebClient.tooling.tokens.rotate({
refresh_token: appConfigurationRefreshToken,
}),
})
await patchAppCredentials(client, ctx, {
appConfigurationRefreshToken: refresh_token,
})
logger.forBot().debug('Rotated Slack app configuration token successfully')
return rotatedToken!
}
public async validateManifest(manifest: SlackAppManifest) {
surfaceSlackErrors({
logger: this._logger,
response: await this._slackWebClient.apps.manifest.validate({
token: this._appConfigurationToken,
manifest: JSON.stringify(manifest),
}),
})
}
public async createApp(manifest: SlackAppManifest): Promise<ManifestCreateResponse> {
const response = surfaceSlackErrors<SlackWebClient.AppsManifestCreateResponse>({
logger: this._logger,
response: await this._slackWebClient.apps.manifest.create({
token: this._appConfigurationToken,
manifest: JSON.stringify(manifest),
}),
})
const { data, success } = manifestCreateResponseSchema.safeParse(response)
if (!success) {
throw new RuntimeError(`Unexpected response from Slack manifest create API: ${JSON.stringify(response)}`)
}
return data
}
public async exportApp(appId: string): Promise<SlackAppManifest> {
const response = surfaceSlackErrors<SlackWebClient.AppsManifestExportResponse>({
logger: this._logger,
response: await this._slackWebClient.apps.manifest.export({
token: this._appConfigurationToken,
app_id: appId,
}),
})
const { data, success } = manifestSchema.safeParse(response.manifest)
if (!success) {
throw new RuntimeError(`Unexpected response from Slack manifest export API: ${JSON.stringify(response.manifest)}`)
}
return data
}
public async updateApp(appId: string, manifest: SlackAppManifest): Promise<void> {
surfaceSlackErrors({
logger: this._logger,
response: await this._slackWebClient.apps.manifest.update({
token: this._appConfigurationToken,
app_id: appId,
manifest: JSON.stringify(manifest),
}),
})
}
public async updateAppIfNeeded(appId: string, desiredManifest: SlackAppManifest): Promise<boolean> {
const currentManifest = await this.exportApp(appId)
if (JSON.stringify(currentManifest) === JSON.stringify(desiredManifest)) {
this._logger.forBot().debug('Slack app manifest is up to date, skipping update')
return false
}
this._logger.forBot().debug('Slack app manifest has changed, updating...')
await this.updateApp(appId, desiredManifest)
return true
}
}
export const buildSlackAppManifest = (webhookUrl: string, redirectUri: string, appName: string): SlackAppManifest => ({
display_information: {
name: appName,
},
features: {
bot_user: {
display_name: appName,
always_online: true,
},
},
oauth_config: {
scopes: {
bot: REQUIRED_SLACK_SCOPES,
},
redirect_urls: [redirectUri],
token_management_enabled: true,
},
settings: {
event_subscriptions: {
request_url: webhookUrl,
bot_events: BOT_EVENTS,
},
interactivity: {
is_enabled: true,
request_url: webhookUrl,
},
org_deploy_enabled: false,
socket_mode_enabled: false,
token_rotation_enabled: true,
},
})
@@ -0,0 +1,302 @@
import * as sdk from '@botpress/sdk'
import { type OauthV2AccessResponse, WebClient as SlackWebClient } from '@slack/web-api'
import { handleErrorsDecorator as handleErrors, redactSlackError } from './error-handling'
import * as bp from '.botpress'
type PrivateAuthState = {
readonly accessToken: {
readonly issuedAt: Date
readonly expiresAt: Date
readonly token: string
}
readonly refreshToken: {
readonly issuedAt: Date
readonly token: string
}
readonly scopes: string[]
readonly botUserId: string
readonly teamId: string
}
type PublicAuthState = {
readonly accessToken: string
readonly scopes: string[]
readonly botUserId: string
readonly teamId: string
}
const MINIMUM_TOKEN_VALIDITY_SECONDS = 7_200 // 2 hours
export class SlackOAuthClient {
private readonly _client: bp.Client
private readonly _ctx: bp.Context
private readonly _logger: bp.Logger
private readonly _clientId: string
private readonly _clientSecret: string
private readonly _slackClient: SlackWebClient
private _currentAuthState: PrivateAuthState | undefined = undefined
public constructor({
ctx,
client,
logger,
clientIdOverride,
clientSecretOverride,
}: {
client: bp.Client
ctx: bp.Context
logger: bp.Logger
clientIdOverride?: string
clientSecretOverride?: string
}) {
this._clientId = clientIdOverride ?? bp.secrets.CLIENT_ID
this._clientSecret = clientSecretOverride ?? bp.secrets.CLIENT_SECRET
this._slackClient = new SlackWebClient()
this._client = client
this._ctx = ctx
this._logger = logger
}
@handleErrors('Failed to refresh Slack credentials')
public async getAuthState(): Promise<PublicAuthState> {
await this._refreshAuthStateIfNeeded()
if (!this._currentAuthState) {
throw new sdk.RuntimeError('No credentials found')
}
return {
accessToken: this._currentAuthState.accessToken.token,
scopes: this._currentAuthState.scopes,
botUserId: this._currentAuthState.botUserId,
teamId: this._currentAuthState.teamId,
}
}
public readonly requestShortLivedCredentials = {
/**
* Exchanges an OAuth callback authorization code for short-lived rotating
* credentials. This code path is used once when the user selects automatic
* configuration using our Botpress Slack app.
*
* @param authorizationCode - The authorization code from Slack's OAuth callback
* @param redirectUri - Optional custom redirect URI. Defaults to `${process.env.BP_WEBHOOK_URL}/oauth`
*/
fromAuthorizationCode: async (authorizationCode: string, redirectUri?: string) => {
this._logger.forBot().debug('Exchanging authorization code for short-lived credentials...')
const response = await this._slackClient.oauth.v2
.access({
client_id: this._clientId,
client_secret: this._clientSecret,
redirect_uri: redirectUri ?? `${process.env.BP_WEBHOOK_URL}/oauth`,
grant_type: 'authorization_code',
code: authorizationCode,
})
.catch((thrown) => {
throw redactSlackError(thrown, 'Failed to exchange authorization code')
})
this._currentAuthState = this._parseSlackOAuthResponse(response)
await this._saveCredentialsV2()
this._logger.debug('Successfully exchanged & revoked authorization code')
},
/**
* Exchanges a single-use refresh token for short-lived rotating
* credentials. This code path is used once when the user chooses manual
* configuration and provides a single-use refresh token, or when the user
* chooses automatic configuration and the credentials are about to expire.
*/
fromRefreshToken: async (refreshToken: string) => {
this._logger.forBot().debug('Exchanging refresh token for short-lived credentials...')
const response = await this._slackClient.oauth.v2
.access({
client_id: this._clientId,
client_secret: this._clientSecret,
refresh_token: refreshToken,
grant_type: 'refresh_token',
})
.catch((thrown) => {
throw redactSlackError(thrown, 'Failed to exchange refresh token')
})
this._currentAuthState = this._parseSlackOAuthResponse(response)
await this._saveCredentialsV2()
this._logger.debug('Successfully exchanged & revoked refresh token')
},
/**
* Exchanges a long-lived bot token for short-lived rotating credentials.
* This code path is used when the user previously used manual configuration
* before the integration supported rotating credentials.
*/
fromLegacyBotToken: async (legacyBotToken: string) => {
this._logger.forBot().debug('Exchanging legacy bot token for short-lived credentials...')
const exchangeClient = new SlackWebClient(legacyBotToken)
const response = await exchangeClient.oauth.v2
.exchange({
client_id: this._clientId,
client_secret: this._clientSecret,
})
.catch((thrown) => {
throw redactSlackError(thrown, 'Failed to exchange bot token')
})
this._currentAuthState = this._parseSlackOAuthResponse(response)
await this._saveCredentialsV2()
this._logger.debug('Successfully exchanged & revoked legacy bot token')
},
}
private async _refreshAuthStateIfNeeded(): Promise<void> {
if (this._isTokenStillValid()) {
return
}
const credentialsV2 = await this._getCredentialsStateV2()
if (credentialsV2) {
return await this._refreshAuthV2(credentialsV2)
}
const credentialsV1 = await this._getCredentialsStateV1()
if (credentialsV1) {
return await this.requestShortLivedCredentials.fromLegacyBotToken(credentialsV1.accessToken)
}
throw new sdk.RuntimeError('No credentials found')
}
private _isTokenStillValid() {
return this._currentAuthState && this._currentAuthState.accessToken.expiresAt > this._getMinExpiryDate()
}
private _getMinExpiryDate() {
return new Date(Date.now() + MINIMUM_TOKEN_VALIDITY_SECONDS * 1000)
}
private async _getCredentialsStateV2() {
try {
const { state: credentialsV2 } = await this._client.getState({
type: 'integration',
id: this._ctx.integrationId,
name: 'oAuthCredentialsV2',
})
return credentialsV2.payload
} catch {}
return
}
/**
* @deprecated - Remove this entire code path when we remove the 'credentials' state
*/
private async _getCredentialsStateV1() {
try {
const { state: credentialsV1 } = await this._client.getState({
type: 'integration',
id: this._ctx.integrationId,
name: 'credentials',
})
return credentialsV1.payload
} catch {}
return
}
private async _refreshAuthV2(credentials: bp.states.oAuthCredentialsV2.OAuthCredentialsV2['payload']) {
const { shortLivedAccessToken, rotatingRefreshToken, grantedScopes, botUserId, teamId } = credentials
const tokenExpiresAt = new Date(shortLivedAccessToken.expiresAt)
if (tokenExpiresAt > this._getMinExpiryDate()) {
this._currentAuthState = {
accessToken: {
expiresAt: tokenExpiresAt,
issuedAt: new Date(shortLivedAccessToken.issuedAt),
token: shortLivedAccessToken.currentAccessToken,
},
refreshToken: {
issuedAt: new Date(rotatingRefreshToken.issuedAt),
token: rotatingRefreshToken.token,
},
scopes: grantedScopes,
botUserId,
teamId,
}
return
}
await this.requestShortLivedCredentials.fromRefreshToken(rotatingRefreshToken.token)
}
private _parseSlackOAuthResponse(response: OauthV2AccessResponse): PrivateAuthState {
if (
!response.access_token ||
!response.refresh_token ||
!response.expires_in ||
!response.scope ||
!response.bot_user_id ||
!response.team?.id
) {
console.error('Slack OAuth response is missing required fields', response)
throw new sdk.RuntimeError('OAuth response is missing required fields')
}
if (response.expires_in < MINIMUM_TOKEN_VALIDITY_SECONDS) {
console.error('Slack OAuth response has an invalid expiration time', response.expires_in)
throw new sdk.RuntimeError('OAuth response has an invalid expiration time')
}
const issuedAt = new Date()
const expiresAt = new Date(issuedAt.getTime() + response.expires_in * 1000)
return {
accessToken: {
issuedAt,
expiresAt,
token: response.access_token,
},
refreshToken: {
issuedAt,
token: response.refresh_token,
},
scopes: response.scope.split(','),
botUserId: response.bot_user_id,
teamId: response.team.id,
} as const
}
private async _saveCredentialsV2() {
if (!this._currentAuthState) {
throw new sdk.RuntimeError('No credentials to save')
}
await this._client.setState({
type: 'integration',
name: 'oAuthCredentialsV2',
id: this._ctx.integrationId,
payload: {
shortLivedAccessToken: {
currentAccessToken: this._currentAuthState.accessToken.token,
issuedAt: this._currentAuthState.accessToken.issuedAt.toISOString(),
expiresAt: this._currentAuthState.accessToken.expiresAt.toISOString(),
},
rotatingRefreshToken: {
token: this._currentAuthState.refreshToken.token,
issuedAt: this._currentAuthState.refreshToken.issuedAt.toISOString(),
},
grantedScopes: this._currentAuthState.scopes,
botUserId: this._currentAuthState.botUserId,
teamId: this._currentAuthState.teamId,
},
})
}
}
@@ -0,0 +1,34 @@
import * as sdk from '@botpress/sdk'
type SlackClient = {
hasAllScopes: (requiredScopes: string[]) => boolean
getGrantedScopes: () => Readonly<string[]>
}
export const requiresAllScopesDecorator =
(requiredScopes: [string, ...string[]], operation?: string) =>
(slackClient: SlackClient, methodName: string, descriptor: PropertyDescriptor): void => {
const _originalMethod: (...args: unknown[]) => Promise<unknown> = descriptor.value
descriptor.value = function (...args: unknown[]) {
// This function replaces the property descriptor (the class method) with
// a new function. Within the context of this function, `this` refers to
// the instance of the class (SlackClient) where the method is defined.
// We use .apply and .call to ensure that instance methods are attached to
// the actual instance of SlackClient.
if (!slackClient.hasAllScopes.apply(this, [requiredScopes])) {
const grantedScopes = slackClient.getGrantedScopes.call(this)
const missingScopes = requiredScopes.filter((scope) => !grantedScopes.includes(scope))
throw new sdk.RuntimeError(
`The Slack access token is missing required scopes to perform operation "${operation ?? methodName ?? descriptor.value?.name}". ` +
'Please re-authorize the app. \n\n' +
`Scopes required for this operation: ${requiredScopes.join(', ')}. \n` +
`Missing scopes: ${missingScopes.join(', ')}. \n` +
`Scopes granted to the app: ${grantedScopes.join(', ')}.`
)
}
return _originalMethod.apply(this, args)
}
}
@@ -0,0 +1,123 @@
import { isOAuthWizardUrl } from '@botpress/common/src/oauth-wizard'
import * as sdk from '@botpress/sdk'
import type { SlackEvent } from '@slack/types'
import { safeParseBody } from 'src/misc/utils'
import { oauthWizardHandler } from '../oauth-wizard'
import { getAppCredentials } from '../slack-api/slack-manifest-client'
import * as handlers from './handlers'
import { handleInteractiveRequest, isInteractiveRequest } from './handlers/interactive-request'
import { isOAuthCallback, handleOAuthCallback } from './handlers/oauth-callback'
import { isUrlVerificationRequest, handleUrlVerificationRequest } from './handlers/url-verification'
import { SlackEventSignatureValidator } from './signature-validator'
import * as bp from '.botpress'
export const handler: bp.IntegrationProps['handler'] = async ({ req, ctx, client, logger }) => {
logger.forBot().debug('Handler received request from Slack with payload:', req.body)
if (isOAuthWizardUrl(req.path)) {
return await oauthWizardHandler({ req, client, logger, ctx })
}
if (isOAuthCallback(req)) {
return await handleOAuthCallback({ req, client, logger, ctx })
}
_verifyBodyIsPresent(req)
const parseRes = safeParseBody(req.body)
if (!parseRes.success) {
const { error } = parseRes
logger.forBot().error('could not parse the JSON', error)
}
const { data } = parseRes
if (isUrlVerificationRequest(data)) {
logger.forBot().debug('Handler received request of type url_verification')
return handleUrlVerificationRequest(data)
}
await _verifyMessageIsProperlyAuthenticated({ req, client, logger, ctx })
if (isInteractiveRequest(req)) {
return await handleInteractiveRequest({ req, client, logger, ctx })
}
const event: SlackEvent = data.event
logger.forBot().debug(`Handler received request of type ${data.event.type}`)
if (await _isEventProducedByBot({ client, ctx }, event)) {
logger.forBot().debug('Ignoring event produced by the bot itself')
return
}
await _dispatchEvent({ client, ctx, logger, req }, event)
}
function _verifyBodyIsPresent(req: sdk.Request): asserts req is sdk.Request & { body: string } {
if (!req.body) {
throw new sdk.RuntimeError('Handler received a request with an empty body')
}
}
const _verifyMessageIsProperlyAuthenticated = async ({ req, logger, ...props }: bp.HandlerProps) => {
const signingSecret = await _getSigningSecret({ logger, ...props })
const isSignatureValid = new SlackEventSignatureValidator(signingSecret, req, logger).isEventProperlyAuthenticated()
if (!isSignatureValid) {
throw new sdk.RuntimeError('Handler received a request with an invalid signature')
}
}
const _isEventProducedByBot = async (
{ client, ctx }: { client: bp.Client; ctx: bp.Context },
event: SlackEvent
): Promise<boolean> => {
if ('bot_id' in event && event.bot_id) {
return true
}
return (
'user' in event &&
event.user ===
(await client.getState({ type: 'integration', name: 'oAuthCredentialsV2', id: ctx.integrationId })).state.payload
.botUserId
)
}
const _dispatchEvent = async ({ client, ctx, logger }: bp.HandlerProps, slackEvent: SlackEvent) => {
switch (slackEvent.type) {
case 'message':
return await handlers.messageReceived.handleEvent({ slackEvent, client, ctx, logger })
case 'reaction_added':
return await handlers.reactionAdded.handleEvent({ slackEvent, client })
case 'reaction_removed':
return await handlers.reactionRemoved.handleEvent({ slackEvent, client })
case 'team_join':
return await handlers.memberJoinedWorkspace.handleEvent({ slackEvent, client })
case 'member_joined_channel':
return await handlers.memberJoinedChannel.handleEvent({ slackEvent, client })
case 'member_left_channel':
return await handlers.memberLeftChannel.handleEvent({ slackEvent, client })
case 'function_executed':
return await handlers.functionExecuted.handleEvent({ slackEvent, client, logger })
default:
logger.forBot().debug(`Ignoring unsupported event type ${slackEvent.type}`)
return
}
}
const _getSigningSecret = async ({ client, ctx }: bp.CommonHandlerProps): Promise<string> => {
const appCreds = await getAppCredentials(client, ctx)
if (appCreds.signingSecret) {
return appCreds.signingSecret
}
return bp.secrets.SIGNING_SECRET
}
@@ -0,0 +1,42 @@
import type { FunctionExecutedEvent } from '@slack/types'
import type * as bp from '.botpress'
export const handleEvent = async ({
slackEvent,
logger,
client,
}: {
slackEvent: FunctionExecutedEvent
client: bp.Client
logger: bp.Logger
}) => {
if (slackEvent.function.callback_id !== 'botpress-webook') {
logger.forBot().debug(`Ignoring unsupported workflow function ${slackEvent.function.callback_id}`)
return
}
const inputValues = slackEvent.inputs.value
const inputUserId = slackEvent.inputs.userId
if (!Array.isArray(inputValues)) {
logger.forBot().debug(`Ignoring unsupported workflow function input value type ${typeof inputValues}`)
return
}
const userId = typeof inputUserId === 'string' ? inputUserId : undefined
if (inputUserId && typeof inputUserId !== 'string') {
logger.forBot().debug(`Ignoring unsupported workflow function input userId type ${typeof inputUserId}`)
return
}
const inputValue = inputValues[0]
await client.createEvent({
type: 'workflowWebhook',
payload: {
userId,
value: inputValue,
},
})
}
@@ -0,0 +1,8 @@
export * as urlVerification from './url-verification'
export * as memberJoinedChannel from './member-joined-channel'
export * as memberLeftChannel from './member-left-channel'
export * as messageReceived from './message-received'
export * as reactionAdded from './reaction-added'
export * as reactionRemoved from './reaction-removed'
export * as memberJoinedWorkspace from './member-joined-workspace'
export * as functionExecuted from './function-executed'
@@ -0,0 +1,92 @@
import * as sdk from '@botpress/sdk'
import axios from 'axios'
import { getBotpressConversationFromSlackThread, getBotpressUserFromSlackUser } from '../../misc/utils'
import * as bp from '.botpress'
export const isInteractiveRequest = (req: sdk.Request) =>
req.body?.startsWith('payload=') || req.path === '/interactive'
export const handleInteractiveRequest = async ({ req, client, logger }: bp.HandlerProps) => {
const body = _parseInteractiveBody(req)
const actionValue = await _respondInteractive(body)
if (body.type !== 'block_actions') {
logger.forBot().error(`Interaction type ${body.type} received from Slack is not supported yet`)
return
}
if (typeof actionValue !== 'string' || !actionValue?.length) {
logger.forBot().debug('No action value was returned, so the message was ignored')
return
}
const { botpressUserId: userId } = await getBotpressUserFromSlackUser({ slackUserId: body.user.id }, client)
const { botpressConversationId: conversationId } = await getBotpressConversationFromSlackThread(
{ slackChannelId: body.channel.id },
client
)
await client.getOrCreateMessage({
tags: {
ts: body.message.ts,
userId: body.user.id,
channelId: body.channel.id,
},
type: 'text',
payload: { text: actionValue },
userId,
conversationId,
})
}
type InteractiveBody = {
response_url: string
actions: {
action_id?: string
block_id?: string
value?: string
type: string
selected_option?: { value: string }
}[]
type: string
channel: {
id: string
}
user: {
id: string
}
message: {
ts: string
}
}
const _parseInteractiveBody = (req: sdk.Request): InteractiveBody => {
try {
return JSON.parse(decodeURIComponent(req.body!).replace('payload=', ''))
} catch (thrown: unknown) {
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
throw new sdk.RuntimeError('Body is invalid for interactive request', error)
}
}
const _respondInteractive = async (body: InteractiveBody): Promise<string> => {
if (!body.actions.length) {
throw new sdk.RuntimeError('No action in body')
}
const action = body.actions[0]
const text = action?.value || action?.selected_option?.value || action?.action_id
if (text === undefined) {
throw new sdk.RuntimeError('Action value cannot be undefined')
}
try {
await axios.post(body.response_url, { text })
return text
} catch (thrown: unknown) {
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
throw new sdk.RuntimeError('Error while responding to interactive request', error)
}
}
@@ -0,0 +1,38 @@
import { MemberJoinedChannelEvent } from '@slack/types'
import { getBotpressUserFromSlackUser, getBotpressConversationFromSlackThread } from '../../misc/utils'
import * as bp from '.botpress'
export const handleEvent = async ({
slackEvent,
client,
}: {
slackEvent: MemberJoinedChannelEvent
client: bp.Client
}) => {
const { user: slackUserId, channel: slackChannelId, inviter: slackInviterId } = slackEvent
const { botpressUserId } = await getBotpressUserFromSlackUser({ slackUserId }, client)
const { botpressConversationId } = await getBotpressConversationFromSlackThread({ slackChannelId }, client)
let inviterBotpressUserId
if (slackInviterId) {
const inviter = await getBotpressUserFromSlackUser({ slackUserId: slackInviterId }, client)
inviterBotpressUserId = inviter.botpressUserId
}
await client.createEvent({
type: 'memberJoinedChannel',
payload: {
botpressUserId,
botpressConversationId,
inviterBotpressUserId,
targets: {
slackUserId,
slackChannelId,
slackInviterId,
},
},
conversationId: botpressConversationId,
userId: botpressUserId,
})
}
@@ -0,0 +1,23 @@
import { TeamJoinEvent } from '@slack/types'
import { getBotpressUserFromSlackUser } from '../../misc/utils'
import * as bp from '.botpress'
export const handleEvent = async ({ slackEvent, client }: { slackEvent: TeamJoinEvent; client: bp.Client }) => {
const slackUserId = slackEvent.user.id
const { botpressUserId } = await getBotpressUserFromSlackUser({ slackUserId }, client)
await client.createEvent({
type: 'memberJoinedWorkspace',
payload: {
userId: botpressUserId,
target: {
userId: slackUserId,
userName: slackEvent.user.name,
userRealName: slackEvent.user.real_name,
userDisplayName: slackEvent.user.profile.display_name,
},
},
userId: botpressUserId,
})
}
@@ -0,0 +1,30 @@
import { MemberLeftChannelEvent } from '@slack/types'
import { getBotpressUserFromSlackUser, getBotpressConversationFromSlackThread } from '../../misc/utils'
import * as bp from '.botpress'
export const handleEvent = async ({
slackEvent,
client,
}: {
slackEvent: MemberLeftChannelEvent
client: bp.Client
}) => {
const { user: slackUserId, channel: slackChannelId } = slackEvent
const { botpressUserId } = await getBotpressUserFromSlackUser({ slackUserId }, client)
const { botpressConversationId } = await getBotpressConversationFromSlackThread({ slackChannelId }, client)
await client.createEvent({
type: 'memberLeftChannel',
payload: {
botpressUserId,
botpressConversationId,
targets: {
slackUserId,
slackChannelId,
},
},
conversationId: botpressConversationId,
userId: botpressUserId,
})
}
@@ -0,0 +1,386 @@
import { z } from '@botpress/sdk'
import { slackToMarkdown } from '@bpinternal/slackdown'
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 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 = {
slackEvent: AllMessageEvents
client: bp.Client
ctx: bp.Context
logger: bp.Logger
}
export const handleEvent = async (props: HandleEventProps) => {
const { slackEvent, client, ctx, logger } = props
// do not handle notification-type messages such as channel_join, channel_leave, etc:
if (slackEvent.subtype && slackEvent.subtype !== 'file_share') {
return
}
const { botpressUser } = await getBotpressUserFromSlackUser({ slackUserId: slackEvent.user }, client)
await updateBotpressUserFromSlackUser(slackEvent.user, botpressUser, client, ctx, logger)
const mentionsBot = await _isBotMentionedInMessage({ slackEvent, client, ctx })
const isSentInChannel = !slackEvent.thread_ts
const replyLocation = ctx.configuration.replyBehaviour?.location ?? 'channel'
const replyOnlyOnBotMention = ctx.configuration.replyBehaviour?.onlyOnBotMention ?? false
if (replyOnlyOnBotMention && !mentionsBot) {
logger.forBot().warn('Message was not sent because the bot was not mentioned')
return
}
const shouldRespondInChannel =
isSentInChannel && (replyLocation === 'channel' || replyLocation === 'channelAndThread')
const shouldRespondInThread = !isSentInChannel || replyLocation === 'thread' || replyLocation === 'channelAndThread'
if (shouldRespondInChannel) {
const { botpressConversation } = await getBotpressConversationFromSlackThread(
{ slackChannelId: slackEvent.channel, slackThreadId: undefined },
client
)
await _sendMessage({
botpressConversation,
botpressUser,
tags: {
ts: slackEvent.ts,
userId: slackEvent.user,
channelId: slackEvent.channel,
mentionsBot: mentionsBot ? 'true' : undefined,
forkedToThread: 'false',
},
discriminateByTags: ['ts', 'channelId'],
slackEvent,
client,
ctx,
logger,
})
}
if (shouldRespondInThread) {
const threadTs = slackEvent.thread_ts ?? slackEvent.ts
const { conversation: threadConversation } = await client.getOrCreateConversation({
channel: 'thread',
tags: { id: slackEvent.channel, thread: threadTs, isBotReplyThread: 'true' },
discriminateByTags: ['id', 'thread'],
})
await _sendMessage({
botpressConversation: threadConversation,
botpressUser,
tags: {
ts: slackEvent.ts,
userId: slackEvent.user,
channelId: slackEvent.channel,
mentionsBot: mentionsBot ? 'true' : undefined,
forkedToThread: isSentInChannel ? 'true' : 'false',
},
discriminateByTags: ['ts', 'channelId', 'forkedToThread'],
slackEvent,
client,
ctx,
logger,
})
}
}
type _SendMessageProps = HandleEventProps & {
botpressConversation: { id: string }
botpressUser: { id: string }
tags: Record<MessageTag, string | undefined>
discriminateByTags: MessageTag[] | undefined
}
const _sendMessage = async (props: _SendMessageProps) => {
const { slackEvent, client, ctx, logger, botpressConversation, botpressUser, tags, discriminateByTags } = props
if (slackEvent.subtype === 'file_share') {
await _getOrCreateMessageFromFiles({
ctx,
botpressConversation,
botpressUser,
slackEvent,
client,
logger,
tags,
discriminateByTags,
})
return
}
if (slackEvent.subtype) {
return
}
const text = _parseSlackEventText(slackEvent)
if (!text) {
logger.forBot().debug('No text was received, so the message was ignored')
return
}
await client.getOrCreateMessage({
type: 'text',
payload: await _getTextPayloadFromSlackEvent(slackEvent, client, ctx, logger),
userId: botpressUser.id,
conversationId: botpressConversation.id,
tags,
discriminateByTags,
})
}
const _isBotMentionedInMessage = async ({
slackEvent,
client,
ctx,
}: {
slackEvent: AllMessageEvents
client: bp.Client
ctx: bp.Context
}) => {
if (slackEvent.subtype || !slackEvent.text) {
return false
}
const slackBotId = await _getSlackBotIdFromStates(client, ctx)
if (!slackBotId) {
return false
}
return (
slackEvent.text.includes(`<@${slackBotId}>`) ||
(slackEvent.blocks?.some(
(block) =>
'elements' in block &&
block.elements.some(
(element) =>
'elements' in element &&
element.elements.some((subElement) => subElement.type === 'user' && subElement.user_id === slackBotId)
)
) ??
false)
)
}
const _getSlackBotIdFromStates = async (client: bp.Client, ctx: bp.Context) => {
try {
const { state } = await client.getState({
type: 'integration',
name: 'oAuthCredentialsV2',
id: ctx.integrationId,
})
return state.payload.botUserId
} catch {}
return
}
const _getOrCreateMessageFromFiles = async ({
ctx,
botpressUser,
botpressConversation,
slackEvent,
client,
logger,
tags,
discriminateByTags,
}: _SendMessageProps & {
slackEvent: FileShareMessageEvent
}) => {
const parsedEvent = _parseFileSlackEvent(slackEvent)
if (!parsedEvent.type) {
return
}
const { id: userId } = botpressUser
const { id: conversationId } = botpressConversation
if (parsedEvent.type === 'file') {
const { payload: file } = parsedEvent
const blocItem = _parseSlackFile(logger, file)
if (!blocItem) {
return
}
await client.getOrCreateMessage({
tags,
userId,
conversationId,
discriminateByTags,
...blocItem,
} as bp.ClientRequests['getOrCreateMessage'])
return
}
const items: BlocItem[] = []
if (slackEvent.text) {
items.push({ type: 'text', payload: await _getTextPayloadFromSlackEvent(slackEvent, client, ctx, logger) })
}
for (const file of parsedEvent.items) {
const item = _parseSlackFile(logger, file)
if (item) {
items.push(item)
}
}
await client.getOrCreateMessage({
tags,
userId,
conversationId,
discriminateByTags,
type: 'bloc',
payload: { items },
})
}
const _parseSlackFile = (logger: bp.Logger, file: File): BlocItem | null => {
const fileType = file.mimetype.split('/')[0]
if (!fileType) {
logger.forBot().info('File of type', fileType, 'is not yet supported.')
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 } }
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 } }
case 'text':
return { type: 'file', payload: { fileUrl: file.permalink_public } }
default:
logger.forBot().info('File of type', fileType, 'is not yet supported.')
return null
}
}
const _parseSlackEventText = (slackEvent: GenericMessageEvent | FileShareMessageEvent): string | null => {
if (slackEvent.text === undefined || typeof slackEvent.text !== 'string' || !slackEvent.text?.length) {
return null
}
return slackEvent.text
}
type File = NonNullable<FileShareMessageEvent['files']>[number]
type _ParsedFileSlackEvent =
| {
type: null
}
| {
type: 'file'
payload: File
}
| {
type: 'bloc'
text: string | null
items: File[]
}
const _parseFileSlackEvent = (slackEvent: FileShareMessageEvent): _ParsedFileSlackEvent => {
if (!slackEvent.files) {
return { type: null }
}
const [file] = slackEvent.files
if (!file) {
return { type: null }
}
const text = _parseSlackEventText(slackEvent)
if (slackEvent.files.length === 1 && !text) {
return { type: 'file', payload: file }
}
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 as BlockElement[]) : []))
.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 }
}
@@ -0,0 +1,22 @@
import * as sdk from '@botpress/sdk'
import { SlackClient } from 'src/slack-api'
import { getAppCredentials } from 'src/slack-api/slack-manifest-client'
import * as bp from '.botpress'
export const isOAuthCallback = (req: sdk.Request): req is sdk.Request & { path: '/oauth' } => req.path === '/oauth'
export const handleOAuthCallback = async ({ req, client, ctx, logger }: bp.HandlerProps) => {
logger.forBot().debug('OAuth callback handled in webhook handler, redirecting to end step')
const query = new URLSearchParams(req.query)
const code = query.get('code')
if (!code || typeof code !== 'string') {
throw new Error('Handler received an empty code')
}
const slackClient = await SlackClient.createFromAuthorizationCode({ client, ctx, logger, authorizationCode: code })
const appCreds = await getAppCredentials(client, ctx)
const identifier = appCreds.clientId ? slackClient.getBotUserId() : slackClient.getTeamId()
await client.configureIntegration({ identifier })
}
@@ -0,0 +1,35 @@
import { ReactionAddedEvent } from '@slack/types'
import {
getMessageFromSlackEvent,
getBotpressConversationFromSlackThread,
getBotpressUserFromSlackUser,
} from '../../misc/utils'
import * as bp from '.botpress'
export const handleEvent = async ({ slackEvent, client }: { slackEvent: ReactionAddedEvent; client: bp.Client }) => {
const { botpressConversationId } = await getBotpressConversationFromSlackThread(
{ slackChannelId: slackEvent.item.channel },
client
)
const { botpressUserId } = await getBotpressUserFromSlackUser({ slackUserId: slackEvent.user }, client)
const message = await getMessageFromSlackEvent(client, slackEvent)
await client.createEvent({
type: 'reactionAdded',
payload: {
reaction: slackEvent.reaction,
targets: {
dm: { id: slackEvent.user },
thread: 'ts' in slackEvent.item ? { id: slackEvent.item.channel, thread: slackEvent.item.ts } : undefined,
channel: { id: slackEvent.item.channel },
},
userId: botpressUserId,
conversationId: botpressConversationId,
},
userId: botpressUserId,
conversationId: botpressConversationId,
messageId: message?.id,
})
}
@@ -0,0 +1,39 @@
import { ReactionRemovedEvent } from '@slack/types'
import {
getMessageFromSlackEvent,
getBotpressConversationFromSlackThread,
getBotpressUserFromSlackUser,
} from '../../misc/utils'
import * as bp from '.botpress'
export const handleEvent = async ({ slackEvent, client }: { slackEvent: ReactionRemovedEvent; client: bp.Client }) => {
if (slackEvent.item.type !== 'message') {
return
}
const { botpressUserId } = await getBotpressUserFromSlackUser({ slackUserId: slackEvent.user }, client)
const { botpressConversationId } = await getBotpressConversationFromSlackThread(
{ slackChannelId: slackEvent.item.channel },
client
)
const message = await getMessageFromSlackEvent(client, slackEvent)
await client.createEvent({
type: 'reactionRemoved',
payload: {
reaction: slackEvent.reaction,
targets: {
dm: { id: slackEvent.user },
thread: { id: slackEvent.item.channel, thread: slackEvent.item.ts },
channel: { id: slackEvent.item.channel },
},
userId: botpressUserId,
conversationId: botpressConversationId,
},
conversationId: botpressConversationId,
userId: botpressUserId,
messageId: message?.id,
})
}
@@ -0,0 +1,13 @@
import * as sdk from '@botpress/sdk'
const URL_VERIFICATION_SCHEMA = sdk.z.object({
type: sdk.z.literal('url_verification'),
challenge: sdk.z.string(),
})
export const isUrlVerificationRequest = (data: unknown): data is sdk.z.infer<typeof URL_VERIFICATION_SCHEMA> =>
URL_VERIFICATION_SCHEMA.safeParse(data).success
export const handleUrlVerificationRequest = (data: sdk.z.infer<typeof URL_VERIFICATION_SCHEMA>) => ({
body: JSON.stringify({ challenge: data.challenge }),
})
@@ -0,0 +1 @@
export { handler } from './handler-dispatcher'
@@ -0,0 +1,102 @@
import { describe, it, expect } from 'vitest'
import * as crypto from 'crypto'
import { SlackEventSignatureValidator } from './signature-validator'
const VALID_SIGNING_SECRET = 'valid-signing-secret'
const INVALID_SIGNING_SECRET = 'invalid-signing-secret'
const mockedLogger = { forBot: () => ({ error: console.error }) } as any
const generateSlackSignature = (secret: string, timestamp: string, body: any) => {
const sigBasestring = `v0:${timestamp}:${body}`
return 'v0=' + crypto.createHmac('sha256', secret).update(sigBasestring).digest('hex')
}
describe('SlackEventSignatureValidator', () => {
it('validates a legitimate Slack request', () => {
const timestamp = Math.floor(Date.now() / 1000).toString()
const body = {}
const signature = generateSlackSignature(VALID_SIGNING_SECRET, timestamp, body)
const mockRequest = {
headers: {
'x-slack-request-timestamp': timestamp,
'x-slack-signature': signature,
},
body,
}
expect(
new SlackEventSignatureValidator(
VALID_SIGNING_SECRET,
mockRequest as any,
mockedLogger
).isEventProperlyAuthenticated()
).toBe(true)
})
it('invalidates a 6 min old legitimate Slack request', () => {
const timestamp = (Math.floor(Date.now() / 1000) - 60 * 6).toString()
const body = {}
const signature = generateSlackSignature(VALID_SIGNING_SECRET, timestamp, body)
const mockRequest = {
headers: {
'x-slack-request-timestamp': timestamp,
'x-slack-signature': signature,
},
body,
}
expect(
new SlackEventSignatureValidator(
VALID_SIGNING_SECRET,
mockRequest as any,
mockedLogger
).isEventProperlyAuthenticated()
).toBe(false)
})
it('invalidates a request with an incorrect signature', () => {
const timestamp = Math.floor(Date.now() / 1000).toString()
const body = {}
const invalidSignature = generateSlackSignature(INVALID_SIGNING_SECRET, timestamp, body)
const mockRequest = {
headers: {
'x-slack-request-timestamp': timestamp,
'x-slack-signature': invalidSignature,
},
body,
}
expect(
new SlackEventSignatureValidator(
VALID_SIGNING_SECRET,
mockRequest as any,
mockedLogger
).isEventProperlyAuthenticated()
).toBe(false)
})
it('invalidates a request with a signature of a different length', () => {
const timestamp = Math.floor(Date.now() / 1000).toString()
const body = {}
const mockRequest = {
headers: {
'x-slack-request-timestamp': timestamp,
'x-slack-signature': '90f4j8032j04392jf043fj9f4230j2f4',
},
body,
}
expect(
new SlackEventSignatureValidator(
VALID_SIGNING_SECRET,
mockRequest as any,
mockedLogger
).isEventProperlyAuthenticated()
).toBe(false)
})
})
@@ -0,0 +1,54 @@
import * as sdk from '@botpress/sdk'
import * as crypto from 'crypto'
import * as bp from '.botpress'
export class SlackEventSignatureValidator {
public constructor(
private readonly _signingSecret: string,
private readonly _request: sdk.Request,
private readonly _logger: bp.Logger
) {}
public isEventProperlyAuthenticated(): boolean {
return this._areHeadersPresent() && this._isTimestampWithinAcceptableRange() && this._isSignatureValid()
}
private _areHeadersPresent(): boolean {
const timestamp = this._request.headers['x-slack-request-timestamp']
const slackSignature = this._request.headers['x-slack-signature']
if (!timestamp || !slackSignature) {
this._logger.forBot().error('Request signature verification failed: missing timestamp or signature')
return false
}
return true
}
private _isTimestampWithinAcceptableRange(): boolean {
const fiveMinutesAgo = Math.floor(Date.now() / 1000) - 60 * 5
const timestamp = this._request.headers['x-slack-request-timestamp'] as string
if (parseInt(timestamp) < fiveMinutesAgo) {
this._logger.forBot().error('Request signature verification failed: timestamp is too old')
return false
}
return true
}
private _isSignatureValid(): boolean {
const sigBasestring = `v0:${this._request.headers['x-slack-request-timestamp']}:${this._request.body}`
const mySignature = 'v0=' + crypto.createHmac('sha256', this._signingSecret).update(sigBasestring).digest('hex')
try {
return crypto.timingSafeEqual(
Buffer.from(mySignature, 'utf8'),
Buffer.from(this._request.headers['x-slack-signature'] as string, 'utf8')
)
} catch {
this._logger.forBot().error('An error occurred while verifying the request signature')
return false
}
}
}