chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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']
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user