chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
.botpress
+209
View File
@@ -0,0 +1,209 @@
import * as sdk from '@botpress/sdk'
type Channel = 'dm' | 'channel'
export type Target = {
displayName: string
tags: { [key: string]: string }
channel: Channel
}
export const actions = {
addReaction: {
title: 'Add Reaction',
description: 'Add a reaction to a message',
input: {
schema: sdk.z.object({
name: sdk.z
.string()
.title('Reaction name')
.describe('The name of the reaction to add, ex: thumbsup')
.placeholder('thumbsup'),
messageId: sdk.z.string().title('Message ID').describe('The ID of the message, ex: {{event.messageId}}'),
}),
},
output: {
schema: sdk.z.object({}),
},
},
findTarget: {
title: 'Find Target',
description: 'Find a target in Slack (ex: a channel, a user to send a dm, etc)',
input: {
schema: sdk.z.object({
query: sdk.z
.string()
.min(2)
.title('Search Query')
.describe('What to search for, ex name of a channel, a user, etc.'),
channel: sdk.z
.enum(['dm', 'channel'])
.title('Channel Name')
.describe('Which channel to look into, ex: dm, channel'),
}),
},
output: {
schema: sdk.z.object({
targets: sdk.z
.array(
sdk.z.object({
displayName: sdk.z.string().title('Display Name').describe('The display name of the target'),
tags: sdk.z.record(sdk.z.string()).title('Tags').describe('The tags of the target'),
channel: sdk.z
.enum(['dm', 'channel'])
.title('Channel type')
.describe('The type of channel of the target'),
})
)
.title('Targets')
.describe('The matching targets'),
}),
},
},
retrieveMessage: {
title: 'Retrieve Message',
description: 'Retrieve a message from Slack',
input: {
schema: sdk.z.object({
ts: sdk.z.string().title('Timestamp').describe('The timestamp of the message to retrieve'),
channel: sdk.z.string().title('Channel').describe('The channel of the message to retrieve'),
}),
},
output: {
schema: sdk.z.object({
type: sdk.z.string().title('Type').describe('The type of the message'),
user: sdk.z.string().title('User').describe('The user who sent the message'),
ts: sdk.z.string().title('Timestamp').describe('The timestamp of the message'),
text: sdk.z.string().title('Text').describe('The text of the message'),
}),
},
},
getOrCreateChannelConversation: {
title: 'Get or Create Channel Conversation',
description: 'Get or create a conversation in a channel',
input: {
schema: sdk.z.object({
conversation: sdk.z.object({
channelId: sdk.z
.string()
.optional()
.title('Channel ID')
.describe('The ID of the channel you want the conversation to be created at'),
}),
}),
},
output: {
schema: sdk.z.object({
conversationId: sdk.z.string().title('Conversation ID').describe('The ID of the new conversation'),
}),
},
},
syncMembers: {
title: 'Sync Members',
description:
'Sync Slack workspace members to Botpress users. This action keeps track of the last sync timestamp and will only sync updated members since the last sync.',
input: {
schema: sdk.z.object({}),
},
output: {
schema: sdk.z.object({
syncedCount: sdk.z.number().title('Synced Count').describe('The number of members synced'),
}),
},
},
updateChannelTopic: {
title: 'Update Channel Topic',
description: 'Update the topic of a channel',
input: {
schema: sdk.z.object({
topic: sdk.z.string().title('New Topic').describe('The new topic of the channel'),
channelId: sdk.z.string().title('Channel ID').describe('The channel id of the target channel'),
}),
},
output: {
schema: sdk.z.object({}),
},
},
getChannelsInfo: {
title: 'Get Channels Info',
description:
'Get information about Slack channels one page at a time. Returns channel details for the current page and a cursor for the next page.',
input: {
schema: sdk.z.object({
includeArchived: sdk.z
.boolean()
.optional()
.title('Include Archived')
.describe('Whether to include archived channels in the results'),
includePrivate: sdk.z
.boolean()
.optional()
.title('Include Private')
.describe('Whether to include private channels in the results'),
includeDm: sdk.z
.boolean()
.optional()
.title('Include Direct Messages')
.describe('Whether to include direct messages in the results'),
cursor: sdk.z
.string()
.optional()
.title('Cursor')
.describe('Pagination cursor for fetching the next page of channels. Omit for the first page.'),
}),
},
output: {
schema: sdk.z.object({
channels: sdk.z
.array(
sdk.z.object({
id: sdk.z.string().title('Channel ID').describe('The Slack ID of the channel'),
name: sdk.z.string().title('Name').describe('The name of the channel'),
topic: sdk.z.string().title('Topic').describe('The topic of the channel'),
purpose: sdk.z.string().title('Purpose').describe('The purpose of the channel'),
numMembers: sdk.z.number().title('Number of Members').describe('The number of members in the channel'),
isPrivate: sdk.z.boolean().title('Is Private').describe('Whether the channel is private'),
isArchived: sdk.z.boolean().title('Is Archived').describe('Whether the channel is archived'),
isDm: sdk.z.boolean().title('Is DM').describe('Whether this is a direct message conversation'),
userId: sdk.z
.string()
.title('User ID')
.describe('The Slack user ID of the other participant (for 1:1 DMs)'),
creator: sdk.z.string().title('Creator').describe('The Slack user ID of the channel creator'),
created: sdk.z.number().title('Created').describe('The Unix timestamp of when the channel was created'),
})
)
.title('Channels')
.describe('List of channels on this page'),
nextCursor: sdk.z
.string()
.title('Next Cursor')
.describe('Cursor for the next page. Empty string if no more pages.'),
}),
},
},
getUserProfile: {
title: 'Get User Profile',
description: 'Get information about a user',
input: {
schema: sdk.z.object({
userId: sdk.z.string().title('User ID').describe('The ID of the user to retrieve information about'),
}),
},
output: {
schema: sdk.z.object({
firstName: sdk.z.string().optional().title('Firstname').describe('The first name of the user'),
lastName: sdk.z.string().optional().title('Lastname').describe('The last name of the user'),
email: sdk.z.string().optional().title('Email').describe('The email of the user'),
displayName: sdk.z.string().optional().title('Display Name').describe('The display name of the user'),
}),
},
},
} as const satisfies sdk.IntegrationDefinitionProps['actions']
@@ -0,0 +1,86 @@
import * as sdk from '@botpress/sdk'
import { textSchema } from './text-input-schema'
const messages = {
...sdk.messages.defaults,
text: {
schema: textSchema,
},
bloc: sdk.messages.markdownBloc,
} as const satisfies sdk.ChannelDefinition['messages']
const conversationTags = {
id: {
title: 'ID',
description: 'The Slack ID of the conversation',
},
title: {
title: 'Title',
description: 'The title of the conversation',
},
} as const satisfies Record<string, Required<sdk.TagDefinition>>
const messageTags = {
ts: {
title: 'Timestamp',
description: 'The timestamp of the message',
},
userId: {
title: 'User ID',
description: 'The Slack ID of the user who sent the message',
},
channelId: {
title: 'Channel ID',
description: 'The Slack ID of the channel where the message was sent',
},
mentionsBot: {
title: 'Mentions Bot?',
description: 'Whether the message mentions the Slack App bot',
},
forkedToThread: {
title: 'Forked to Thread?',
description: 'Whether the message created a thread',
},
} as const satisfies Record<string, Required<sdk.TagDefinition>>
export const channels = {
channel: {
title: 'Channel',
description: 'A general channel',
messages,
message: { tags: messageTags },
conversation: {
tags: { ...conversationTags },
},
},
dm: {
title: 'Direct Message',
description: 'A direct message channel',
messages,
message: { tags: messageTags },
conversation: {
tags: { ...conversationTags },
},
},
thread: {
title: 'Thread',
description: 'A thread inside a channel',
messages,
message: { tags: messageTags },
conversation: {
tags: {
...conversationTags,
thread: {
title: 'Thread ID',
description: 'The Slack ID of the thread',
},
isBotReplyThread: {
title: 'Is Bot Reply Thread?',
description: 'Whether the thread is a bot reply thread',
},
},
},
},
} as const satisfies sdk.IntegrationDefinitionProps['channels']
@@ -0,0 +1 @@
export * from './channels'
@@ -0,0 +1,424 @@
import * as sdk from '@botpress/sdk'
const plainTextSchema = sdk.z.object({ type: sdk.z.literal('plain_text'), text: sdk.z.string() }).strict()
const markdownSchema = sdk.z.object({ type: sdk.z.literal('mrkdwn'), text: sdk.z.string() }).strict()
const plainOrMarkdown = sdk.z.discriminatedUnion('type', [markdownSchema, plainTextSchema])
const imageElement = sdk.z
.object({
type: sdk.z.literal('image'),
image_url: sdk.z.string().describe('The full URL to the image file'),
alt_text: sdk.z.string().describe('Plain text summary of the image'),
})
.strict()
.describe('Display an image to a user')
const imageBlock = sdk.z
.object({
type: sdk.z.literal('image'),
image_url: sdk.z.string().describe('The full URL to the image file'),
alt_text: sdk.z.string().describe('Plain text summary of the image'),
title: plainTextSchema.optional(),
})
.strict()
.describe('Display an image to a user')
const buttonSchema = sdk.z
.object({
type: sdk.z.literal('button'),
action_id: sdk.z.string().describe('A unique identifier for the button'),
text: plainTextSchema,
url: sdk.z.string().optional().describe('An external URL to open when the button is clicked'),
value: sdk.z.string().optional().describe('The value to send along with the interaction payload'),
style: sdk.z
.enum(['primary', 'danger'])
.optional()
.describe('Decorates buttons with alternative visual color schemes. Leave empty for default.'),
})
.strict()
.describe('Button Block. Display a button')
const staticSelectSchema = sdk.z
.object({
type: sdk.z.literal('static_select'),
placeholder: plainTextSchema,
action_id: sdk.z.string(),
options: sdk.z.array(
sdk.z
.object({
text: plainTextSchema,
value: sdk.z.string(),
})
.strict()
),
})
.strict()
const contextBlock = sdk.z
.object({
type: sdk.z.literal('context'),
elements: sdk.z.array(sdk.z.discriminatedUnion('type', [imageElement, ...plainOrMarkdown.options])).max(10),
})
.strict()
.describe('Display multiple elements in a group')
const dividerBlock = sdk.z.object({ type: sdk.z.literal('divider') }).describe('A simple divider block')
const headerBlock = sdk.z
.object({
type: sdk.z.literal('header'),
text: plainTextSchema,
})
.strict()
.describe(
'A header is a plain-text block that displays in a larger, bold font. Use it to delineate between different groups of content in your app surface'
)
const fileBlock = sdk.z
.object({
type: sdk.z.literal('file'),
external_id: sdk.z.string(),
source: sdk.z.literal('remote'),
})
.strict()
.describe('A file block')
const radioButtonsSchema = sdk.z
.object({
type: sdk.z.literal('radio_buttons'),
options: sdk.z.array(
sdk.z
.object({
text: plainTextSchema,
value: sdk.z.string(),
})
.strict()
),
action_id: sdk.z.string(),
})
.strict()
.describe('A radio buttons block')
const checkboxesSchema = sdk.z
.object({
type: sdk.z.literal('checkboxes'),
options: sdk.z.array(
sdk.z.object({
text: plainTextSchema,
value: sdk.z.string(),
})
),
action_id: sdk.z.string(),
})
.strict()
.describe('A checkboxes block')
const overflowSchema = sdk.z
.object({
type: sdk.z.literal('overflow'),
options: sdk.z
.array(
sdk.z
.object({
text: plainTextSchema,
value: sdk.z.string().max(75),
description: plainTextSchema.optional(),
url: sdk.z
.string()
.optional()
.describe(
"A URL to load in the user's browser when the option is clicked. The url attribute is only available in overflow menus."
),
})
.strict()
)
.max(5),
action_id: sdk.z.string(),
})
.strict()
.describe('An overflow block')
const actionId = sdk.z
.string()
.max(255)
.describe(
'An identifier for the input value when the parent modal is submitted. You can use this when you receive a view_submission payload to identify the value of the input element. Should be unique among all other action_ids in the containing block. '
)
const datePickerSchema = sdk.z
.object({
type: sdk.z.literal('datepicker'),
action_id: actionId,
placeholder: plainTextSchema.optional(),
initial_date: sdk.z.string().optional(),
})
.strict()
.describe('A date picker block')
const dateTimePickerSchema = sdk.z
.object({
type: sdk.z.literal('datetimepicker'),
action_id: actionId,
initial_date_time: sdk.z
.number()
.describe(
'The initial date and time that is selected when the element is loaded, represented as a UNUIX timestamp in seconds. This should be in the format of 10 digits, for example 1628633820 represents the date and time August 10th, 2021 at 03:17pm PST'
)
.optional(),
// confirm: // TODO:
focus_on_load: sdk.z.boolean().optional(),
})
.strict()
.describe('A date picker block')
const timePickerSchema = sdk.z
.object({
type: sdk.z.literal('timepicker'),
action_id: actionId,
placeholder: plainTextSchema.optional(),
initial_time: sdk.z.string().optional(),
})
.strict()
.describe('A time picker block')
const optionSchema = sdk.z
.object({
text: plainTextSchema,
value: sdk.z.string(),
})
.strict()
const optionGroupSchema = sdk.z
.object({
label: plainTextSchema,
options: sdk.z.array(optionSchema),
})
.strict()
const multiSelectStaticMenuSchema = sdk.z
.object({
type: sdk.z.literal('multi_static_select'),
placeholder: plainTextSchema,
action_id: sdk.z.string(),
options: sdk.z.array(optionSchema).optional(),
option_groups: sdk.z.array(optionGroupSchema).optional(),
})
.strict()
.refine((obj) => !!obj.options || !!obj.option_groups, {
message: 'At least one of options or option_groups must be provided',
})
.describe('A multi-select menu block')
const conversationsSelectMenuSchema = sdk.z
.object({
type: sdk.z.literal('conversations_select'),
placeholder: plainTextSchema,
action_id: sdk.z.string(),
})
.strict()
.describe('A conversations select menu block')
const channelsSelectMenuSchema = sdk.z
.object({
type: sdk.z.literal('channels_select'),
placeholder: plainTextSchema,
action_id: sdk.z.string(),
})
.strict()
.describe('A channels select menu block')
const usersSelectMenuSchema = sdk.z
.object({
type: sdk.z.literal('users_select'),
placeholder: plainTextSchema,
action_id: sdk.z.string(),
})
.strict()
.describe('A users select menu block')
const externalSelectMenuSchema = sdk.z
.object({
type: sdk.z.literal('external_select'),
placeholder: plainTextSchema,
action_id: sdk.z.string(),
})
.strict()
.describe('An external select menu block')
const conversationsMultiSelectMenuSchema = sdk.z
.object({
type: sdk.z.literal('conversations_multi_select'),
placeholder: plainTextSchema,
action_id: sdk.z.string(),
})
.strict()
.describe('A conversations multi-select menu block')
const channelsMultiSelectMenuSchema = sdk.z
.object({
type: sdk.z.literal('channels_multi_select'),
placeholder: plainTextSchema,
action_id: sdk.z.string(),
})
.strict()
.describe('A channels multi-select menu block')
const usersMultiSelectMenuSchema = sdk.z
.object({
type: sdk.z.literal('users_multi_select'),
placeholder: plainTextSchema,
action_id: sdk.z.string(),
})
.strict()
.describe('A users multi-select menu block')
const externalMultiSelectMenuSchema = sdk.z
.object({
type: sdk.z.literal('external_multi_select'),
placeholder: plainTextSchema,
action_id: sdk.z.string(),
})
.strict()
.describe('An external multi-select menu block')
const plainTextInput = sdk.z
.object({
type: sdk.z.literal('plain_text_input'),
action_id: sdk.z
.string()
.max(255)
.describe(
'An identifier for the input value when the parent modal is submitted. You can use this when you receive a view_submission payload to identify the value of the input element. Should be unique among all other action_ids in the containing block.'
),
initial_value: sdk.z.string().describe('The initial value in the plain-text input when it is loaded.').optional(),
multiline: sdk.z
.boolean()
.optional()
.describe('Indicates whether the input will be a single line (false) or a larger textarea (true'),
min_length: sdk.z.number().min(0).max(3000).optional(),
max_length: sdk.z.number().min(0).max(3000).optional(),
focus_on_load: sdk.z.boolean().optional(),
placeholder: plainTextSchema.optional(),
// TODO: dispatch_action_config
})
.strict()
const multiSelectMenus = sdk.z.discriminatedUnion('type', [
multiSelectStaticMenuSchema.sourceType(),
externalMultiSelectMenuSchema,
usersMultiSelectMenuSchema,
conversationsMultiSelectMenuSchema,
channelsMultiSelectMenuSchema,
])
const selectMenus = sdk.z.discriminatedUnion('type', [
staticSelectSchema,
externalSelectMenuSchema,
usersSelectMenuSchema,
conversationsSelectMenuSchema,
channelsSelectMenuSchema,
])
const inputBlock = sdk.z
.object({
type: sdk.z.literal('input'),
label: plainTextSchema,
element: sdk.z.discriminatedUnion('type', [
plainTextInput,
checkboxesSchema,
radioButtonsSchema,
...selectMenus.options,
...multiSelectMenus.options,
datePickerSchema,
]),
dispatch_action: sdk.z.boolean().optional(),
block_id: sdk.z.string().max(255).optional(),
hint: plainTextSchema.optional(),
optional: sdk.z.boolean().optional(),
})
.strict()
.describe('An input block')
const sectionSchema = sdk.z
.object({
type: sdk.z.literal('section'),
text: plainOrMarkdown.optional(),
fields: sdk.z.array(plainOrMarkdown).min(1).max(10).optional(),
accessory: sdk.z
.discriminatedUnion('type', [
buttonSchema,
checkboxesSchema,
datePickerSchema,
imageElement,
overflowSchema,
radioButtonsSchema,
...multiSelectMenus.options,
...selectMenus.options,
timePickerSchema,
])
.optional(),
})
.strict()
.describe('Show a message using markdown')
const actionsBlock = sdk.z
.object({
type: sdk.z.literal('actions'),
elements: sdk.z
.array(
sdk.z.discriminatedUnion('type', [
buttonSchema,
checkboxesSchema,
datePickerSchema,
dateTimePickerSchema,
overflowSchema,
radioButtonsSchema,
...selectMenus.options,
timePickerSchema,
])
)
.max(5),
})
.describe('Display multiple elements in a group')
.strict()
const blocks = sdk.z.discriminatedUnion('type', [
actionsBlock,
contextBlock,
dividerBlock,
fileBlock,
headerBlock,
imageBlock,
inputBlock,
sectionSchema,
// video // TODO:
])
const mention = sdk.z.object({
type: sdk.z.string(),
start: sdk.z.number(), // position in string
end: sdk.z.number(),
user: sdk.z.object({
id: sdk.z.string(),
name: sdk.z.string(),
}),
})
export const textSchema = sdk.z
.object({
text: sdk.z
.string()
.describe(
'Field text must be defined but it is ignored if blocks are provided. In this situation, the text must be provided in the blocks array'
),
blocks: sdk.z
.array(blocks)
.max(50)
.optional()
.describe(
'Multiple blocks can be added to this array. If a block is provided, the text field is ignored and the text must be added as a block'
),
mentions: sdk.z.array(mention).optional(),
})
.strict()
+146
View File
@@ -0,0 +1,146 @@
import * as sdk from '@botpress/sdk'
export const events = {
reactionAdded: {
title: 'Reaction Added',
description: 'Triggered when a reaction is added to a message',
schema: sdk.z.object({
reaction: sdk.z.string().title('Reaction').describe('The reaction that was added'),
userId: sdk.z.string().optional().title('User ID').describe('The ID of the user who added the reaction'),
conversationId: sdk.z.string().optional().title('Conversation ID').describe('The ID of the conversation'),
targets: sdk.z
.object({
dm: sdk.z.record(sdk.z.string()).optional().title('DMs').describe('The DMs targeted by the reaction'),
channel: sdk.z
.record(sdk.z.string())
.optional()
.title('Channels')
.describe('The channels targeted by the reaction'),
thread: sdk.z
.record(sdk.z.string())
.optional()
.title('Threads')
.describe('The threads targeted by the reaction'),
})
.title('Targets')
.describe('The targets of the reaction'),
}),
},
reactionRemoved: {
title: 'Reaction Removed',
description: 'Triggered when a reaction is removed from a message',
schema: sdk.z.object({
reaction: sdk.z.string().title('Reaction').describe('The reaction that was removed'),
userId: sdk.z.string().optional().title('User ID').describe('The ID of the user who removed the reaction'),
conversationId: sdk.z.string().optional().title('Conversation ID').describe('The ID of the conversation'),
targets: sdk.z
.object({
dm: sdk.z.record(sdk.z.string()).optional(),
channel: sdk.z.record(sdk.z.string()).optional(),
thread: sdk.z.record(sdk.z.string()).optional(),
})
.title('Targets')
.describe('The targets of the reaction'),
}),
},
memberJoinedWorkspace: {
title: 'Member Joined Workspace',
description: 'Triggered when a member joins the workspace',
schema: sdk.z.object({
userId: sdk.z.string().title('Botpress ID').describe('The Botpress ID of the user who joined the workspace'),
target: sdk.z
.object({
userId: sdk.z.string().title('Slack ID').describe('The Slack ID of the user who joined the workspace'),
userName: sdk.z.string().title('Username').describe('The username of the user who joined the workspace'),
userRealName: sdk.z
.string()
.title('Real name')
.describe('The real name of the user who joined the workspace'),
userDisplayName: sdk.z
.string()
.title('Display name')
.describe('The display name of the user who joined the workspace'),
})
.title('Target')
.describe('Slack user who joined the workspace'),
}),
},
memberJoinedChannel: {
title: 'Member Joined Channel',
description: 'Triggered when a member joins a channel',
schema: sdk.z.object({
botpressUserId: sdk.z
.string()
.title('Botpress user ID')
.describe('The Botpress ID of the user who joined the channel'),
botpressConversationId: sdk.z
.string()
.title('Botpress Channel ID')
.describe('The Botpress ID of the channel the user joined'),
inviterBotpressUserId: sdk.z
.string()
.optional()
.title('Botpress Inviter User ID')
.describe('The Botpress ID of the user who invited the new member'),
targets: sdk.z
.object({
slackUserId: sdk.z
.string()
.title('Slack User ID')
.describe('The Slack ID of the user who joined the channel'),
slackChannelId: sdk.z
.string()
.title('Slack Channel ID')
.describe('The Slack ID of the channel the user joined'),
slackInviterId: sdk.z
.string()
.optional()
.title('Slack Inviter ID')
.describe('The Slack ID of the user who invited the new member'),
})
.title('Targets')
.describe('Slack IDs of the user, channel and inviter'),
}),
},
memberLeftChannel: {
title: 'Member Left Channel',
description: 'Triggered when a member leaves a channel',
schema: sdk.z.object({
botpressUserId: sdk.z
.string()
.title('Botpress user ID')
.describe('The Botpress ID of the user who left the channel'),
botpressConversationId: sdk.z
.string()
.title('Botpress Channel ID')
.describe('The Botpress ID of the channel the user left'),
targets: sdk.z
.object({
slackUserId: sdk.z.string().title('Slack User ID').describe('The Slack ID of the user who left the channel'),
slackChannelId: sdk.z
.string()
.title('Slack Channel ID')
.describe('The Slack ID of the channel the user left'),
})
.title('Targets')
.describe('Slack IDs of the user and channel'),
}),
},
workflowWebhook: {
title: 'Workflow Webhook',
description: 'Triggered when the workflow webhook is triggered',
schema: sdk.z.object({
value: sdk.z.any().title('Value').describe('The value of the workflow webhook'),
userId: sdk.z
.string()
.optional()
.title('Slack User ID')
.describe('The Slack ID of the user who triggered the workflow'),
}),
},
} as const satisfies sdk.IntegrationDefinitionProps['events']
+6
View File
@@ -0,0 +1,6 @@
export * from './actions'
export * from './channels'
export * from './events'
export * from './secrets'
export * from './states'
export * from './user-tags'
+15
View File
@@ -0,0 +1,15 @@
import * as sdk from '@botpress/sdk'
export const secrets = {
CLIENT_ID: {
description: 'The client ID of your Slack OAuth app.',
},
CLIENT_SECRET: {
description: 'The client secret of your Slack OAuth app.',
},
SIGNING_SECRET: {
description: 'The signing secret of your Slack OAuth app used to verify requests signature.',
},
} as const satisfies sdk.IntegrationDefinitionProps['secrets']
+67
View File
@@ -0,0 +1,67 @@
import * as sdk from '@botpress/sdk'
export const states = {
sync: {
type: 'integration',
schema: sdk.z.object({
usersLastSyncTs: sdk.z
.number()
.optional()
.title('Users Last Sync Timestamp')
.describe('The timestamp of the last sync'),
}),
},
/** @deprecated - Remove this once we're confident nobody has long-lived access tokens anymore */
credentials: {
type: 'integration',
schema: sdk.z.object({
accessToken: sdk.z.string().secret().title('Long-lived access token').describe('The Bot User OAuth Token'),
signingSecret: sdk.z.string().secret().title('Unused signing secret').describe('The Slack Signing Secret'),
}),
},
oAuthCredentialsV2: {
type: 'integration',
schema: sdk.z.object({
shortLivedAccessToken: sdk.z
.object({
currentAccessToken: sdk.z.string().secret().describe('The Bot User OAuth Access Token'),
issuedAt: sdk.z.string().datetime().describe('The timestamp of when the access token was issued'),
expiresAt: sdk.z.string().datetime().describe('The timestamp of when the access token expires'),
})
.title('Short-lived access token')
.describe('Access token that expires after 12 hours'),
rotatingRefreshToken: sdk.z
.object({
token: sdk.z.string().secret().describe('The Bot User OAuth Refresh Token'),
issuedAt: sdk.z.string().datetime().describe('The timestamp of when the refresh token was issued'),
})
.title('Rotating refresh token')
.describe('Refresh token that does not expire but can be used only once to get a new access token'),
grantedScopes: sdk.z.array(sdk.z.string()).title('Scopes').describe('The scopes granted to the token'),
botUserId: sdk.z.string().title('Slack bot user').describe('The ID of the Slack bot user'),
teamId: sdk.z.string().title('Slack workspace').describe('The ID of the Slack team'),
// Allows re-registering the integration with a revoked refresh token if it hasn't changed:
originalRefreshToken: sdk.z
.string()
.optional()
.title('Original refresh token')
.describe('The now-revoked refresh token that was used to set up the integration'),
}),
},
appCredentials: {
type: 'integration',
schema: sdk.z.object({
clientId: sdk.z.string().optional().title('Client ID').describe('OAuth Client ID'),
clientSecret: sdk.z.string().secret().optional().title('Client Secret').describe('OAuth Client Secret'),
signingSecret: sdk.z.string().secret().optional().title('Signing Secret').describe('The Slack Signing Secret'),
appConfigurationRefreshToken: sdk.z
.string()
.secret()
.optional()
.title('App Configuration Refresh Token')
.describe('Generated from api.slack.com/apps, used for manifest-based setup'),
}),
},
} as const satisfies sdk.IntegrationDefinitionProps['states']
@@ -0,0 +1,91 @@
import * as sdk from '@botpress/sdk'
export const user = {
tags: {
dm_conversation_id: {
title: 'DM Conversation ID',
description:
'The ID of the conversation used to DM the user (created by calling the `startDmConversation` action)',
},
id: {
title: 'ID',
description: 'The Slack ID of the user (U0000XXXXXX)',
},
tz: {
title: 'Timezone',
description: 'The timezone of the user',
},
is_bot: {
title: 'Is a bot',
description: 'Whether the user is a bot',
},
is_admin: {
title: 'Is an admin',
description: 'Whether the user is an admin',
},
title: {
title: 'Profile title',
description: "The on the user's profile",
},
phone: {
title: 'Phone number',
description: 'The phone number of the user',
},
email: {
title: 'Email',
description: 'The email address of the user',
},
real_name: {
title: 'Real name',
description: 'The real name of the user',
},
display_name: {
title: 'Display name',
description: 'The display name of the user',
},
real_name_normalized: {
title: 'Normalized real name',
description: 'The normalized real name of the user',
},
display_name_normalized: {
title: 'Normalized display name',
description: 'The normalized display name of the user',
},
avatar_hash: {
title: 'Avatar hash code',
description: "A hashed representation of the user avatar's avatar",
},
status_text: {
title: 'Status text',
description: 'The status text of the user',
},
status_emoji: {
title: 'Status emoji',
description: 'The status emoji of the user',
},
image_24: {
title: 'Avatar x24',
description: 'The URL of the user avatar (24x24)',
},
image_48: {
title: 'Avatar x48',
description: 'The URL of the user avatar (48x48)',
},
image_192: {
title: 'Avatar x192',
description: 'The URL of the user avatar (192x192)',
},
image_512: {
title: 'Avatar x512',
description: 'The URL of the user avatar (512x512)',
},
image_1024: {
title: 'Avatar x1024',
description: 'The URL of the user avatar (1024x1024)',
},
team: {
title: 'Team',
description: 'The Slack ID of the team (T0000XXXXXX)',
},
},
} as const satisfies sdk.IntegrationDefinitionProps['user']
+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+1
View File
@@ -0,0 +1 @@
parse_json!(replace(decode_percent!(.body), "payload=", "")).team_id
+7
View File
@@ -0,0 +1,7 @@
challenge = parse_json!(.body).challenge
{
"body": encode_json({
"challenge": challenge
})
}
+186
View File
@@ -0,0 +1,186 @@
The Slack integration enables seamless communication between your AI-powered chatbot and Slack, the popular collaboration platform. Connect your chatbot to Slack and streamline team communication, automate tasks, and enhance productivity. With this integration, your chatbot can send and receive messages, share updates, handle inquiries, and perform actions directly within Slack channels. Leverage Slack's extensive features such as chat, file sharing, notifications, and app integrations to create a powerful conversational AI experience. Enhance team collaboration and streamline workflows with the Slack Integration for Botpress.
## Migrating from version `4.x` to `5.x`
Version 5.0 of the Slack integration introduces a unified setup wizard and changes to several actions.
### Configuration changes
The separate configuration modes ("OAuth", "Manual configuration" and "App Manifest") have been replaced by a single unified setup wizard. When you save the integration, the wizard will prompt you to choose between the default Botpress app, creating a new Slack app via App Manifest, or using an existing Slack app. If you previously used a manual or App Manifest configuration, you will need to re-run the setup wizard.
### Action changes
- **`startDmConversation` and `startChannelConversation` have been removed.** Use `getOrCreateChannelConversation` instead by passing the Slack channel ID of the conversation.
- **`getChannelsInfo` is a new action** that returns paginated information about Slack channels, including support for filtering by archived, private, and DM channels. Use the `cursor` parameter to paginate through results.
- **`getOrCreateChannelConversation`** now accepts a `conversation` object with a `channelId` field instead of requiring a Slack user ID.
## Migrating from version `3.x` to `4.x`
Version 4.0 of the Slack integration refines the bot's reply behaviour by introducing the possibility to reply in either `channel`, `thread` or `channel and thread`. This replaces the previous `createReplyThread` configuration option by adding the ability to **only** reply in threads.
Features that have been added are:
- Improved reply behaviour
- Added rich text! Users are now able to input markdown text and it display in rich text in slack
## Migrating from version `2.x` to `3.x`
Version 3.0 of the Slack integration changes the way the mention system works with Botpress.
It now swaps the mention text from slack to fullname and gives a infos about the mention. the payload looks like this:
```JSON
{
text: 'hey <@John Doe>!'
mentions: [
{
type: 'user',
start: 6,
end: 14,
user: {
id: 'user_abc123', // This will be a botpress user id
name: 'John Doe'
}
}
]
}
```
It will also do the same when the bot sends a string with mentions in it. The payload needs to look like this to work.
```JSON
{
text: 'hey <@John Doe>!'
mentions: [
{
type: 'user',
start: 6,
end: 14,
user: {
id: 'U123', // This needs to be a slack member id
name: 'John Doe'
}
}
]
}
```
## Migrating from version `1.x` to `2.x`
Version 2.0 of the Slack integration introduces rotating authentication tokens. If you previously configured the integration using automatic configuration, no action is required once you update to the latest version.
If you configured the integration using manual configuration, you will need to update your Slack app to use rotating tokens. To do this, follow these steps:
1. Go to the Slack API portal and navigate to your app.
2. In the "OAuth & Permissions" section, scroll down to the "Advanced token security via token rotation" section.
3. Click "Opt In" to enable token rotation. Confirm you wish to opt in.
4. Copy the Refresh Token (starts with `xoxe-1-`) or legacy Bot Token (starts with `xoxb-`) and paste it into the integration settings in Botpress. You may need to refresh the page in the Slack API portal to see the new token.
## Configuration
When you add the Slack integration to your bot and save the configuration, a setup wizard will guide you through connecting to Slack. The wizard presents three options:
### Use the default Botpress Slack Application (recommended)
This is the simplest way to set up the integration. A Botpress-managed Slack application will be used to connect to your workspace. Simply select this option in the wizard and you will be redirected to Slack to authorize the app. The application has the necessary permissions to send and receive messages, access channels, and perform other actions on your behalf.
### Configure a new Slack Application (App Manifest)
This option automatically creates a dedicated Slack app for your bot using the Slack App Manifest API. Unlike the default method which uses a shared Botpress-managed Slack app, this gives you your own Slack app with full control, without the manual setup steps.
#### Prerequisites
- A Slack workspace where you have admin permissions.
#### Steps
1. Navigate to [api.slack.com/apps](https://api.slack.com/apps) and log in.
2. Scroll to the bottom of the page and find the **"Your App Configuration Tokens"** section.
3. Click **"Generate Token"** next to the workspace you want to install the bot in. Copy the generated token. Note that configuration tokens expire after 12 hours.
4. In Botpress, add the Slack integration to your bot and save the configuration.
5. In the setup wizard, select **"Configure a new Slack Application"**.
6. Paste your App Configuration Refresh Token and optionally choose a name for the app, then submit.
7. The wizard will automatically create a Slack app with all required scopes, event subscriptions, and interactivity pre-configured.
8. You will be redirected to Slack to authorize the app. Click **"Allow"** to install it in your workspace.
9. Once complete, you will see a success page. The integration is now configured and ready to use.
The created Slack app is fully yours and visible at [api.slack.com/apps](https://api.slack.com/apps).
### Use an already existing Slack Application (Manual)
If you already have a Slack app you want to connect to Botpress, select this option in the wizard and provide your app's credentials.
#### Step 1 - Preparing your Slack application
1. In your browser, navigate to the [Slack API portal](https://api.slack.com/apps) and log in.
2. Open your Slack app (or create a new one).
3. Navigate to the "OAuth & Permissions" section of your Slack app.
4. Scroll down to the "Redirect URLs" section and add the following URL:
```
https://webhook.botpress.cloud/oauth
```
5. Still in the "OAuth & Permissions" section, add the following _Bot Token Scopes_ to your bot token:
- `channels:history`: needed to receive incoming messages and to fetch the history of channels the bot gets invited into.
- `channels:manage`: needed to open new DMs and to set the current topic.
- `channels:read`: needed to obtain a list of all available channels, to retrieve details about conversations, and to receive notifications when a user joins or leaves a channel.
- `chat:write`: needed to send messages as @Botpress in channels or DMs.
- `groups:history`: needed to receive incoming messages and to fetch the history of private channels the bot gets invited into.
- `groups:read`: needed to obtain a list of all available private channels, to retrieve details about conversations, and to receive notifications when a user joins or leaves a private channel.
- `groups:write`: needed to open new DMs and to set the current topic.
- `im:history`: needed to receive incoming messages and to fetch the history of private channels the bot gets invited into.
- `im:read`: needed to obtain a list of all available DMs and to retrieve details about specific DMs.
- `im:write`: needed to open new DMs and to set the current topic of existing DMs.
- `mpim:history`: needed to receive incoming messages and to fetch the history of multi-person DMs the bot gets invited into.
- `mpim:read`: needed to obtain a list of all available multi-person DMs, to retrieve details about conversations, and to receive notifications when a user joins a multi-person DM.
- `mpim:write`: needed to open new DMs and to set the current topic.
- `reactions:read`: needed to receive notifications when reactions are added.
- `reactions:write`: needed to add new reactions to messages.
- `team:read`: needed to obtain metadata on your team in order to operate on the right instance of your bot.
- `users.profile:read`: needed to retrieve profile information for channel and DM members.
- `users:read`: needed to obtain a list of all members of the workspace and to receive notifications when new members join the workspace.
- `users:read.email`: needed for the `Get User Profile` action.
6. Scroll up to the "Advanced token security via token rotation" section and click "Opt In" to enable token rotation. Confirm you wish to opt in.
7. Navigate to the "Basic Information" section and note your **Client ID**, **Client Secret**, and **Signing Secret**.
#### Step 2 - Completing the setup wizard
1. In Botpress, add the Slack integration to your bot and save the configuration.
2. In the setup wizard, select **"Use an already existing Slack Application"**.
3. Enter the **Client ID**, **Client Secret**, and **Signing Secret** from your Slack app.
4. You will be redirected to Slack to authorize the app. Click **"Allow"** to install it in your workspace.
#### Step 3 - Enabling webhooks
1. In the integration settings, copy the webhook URL provided by Botpress. You will need it later.
2. Navigate to the Slack API portal and log in. Open your Slack app.
3. Navigate to the "Event Subscriptions" section of your Slack app.
4. Enable event subscriptions and paste the webhook URL into the "Request URL" field. Save the changes for your Slack app.
5. You may now suscribe to bot events as needed:
- `message.channels`: Subscribe to these events to allow the bot to receive messages from channels.
- `messages.groups`: Subscribe to these events to allow the bot to receive messages from private channels.
- `messages.im`: Subscribe to these events to allow the bot to receive messages from direct messages.
- `messages.mpim`: Subscribe to these events to allow the bot to receive messages from multi-party direct messages.
- `reaction_added`: Subscribe to these events to allow the bot to know when reactions are added to messages.
- `reaction_removed`: Subscribe to these events to allow the bot to know when reactions are removed from messages.
- `member_joined_channel`: Subscribe to these events to allow the bot to know when members join channels.
- `member_left_channel`: Subscribe to these events to allow the bot to know when members leave channels.
- `team_join`: Subscribe to these events to allow the bot to know when new members join the workspace.
6. Save the changes on Slack.
### Optional: Set a custom Display Name and Avatar
Regardless of the configuration mode you choose, you can optionally set a custom display name or avatar for your bot. To do this, fill in the following fields as needed:
- **Bot Name**: If provided, this name will be displayed as the sender in Slack conversations.
- **Bot Avatar URL**: If provided, the bot's avatar will be updated to the image at this URL. The image should be square, at least 512x512 pixels, and no larger than 1024x1024 pixels. The URL must be publicly accessible. Supported formats include GIF, PNG, JPG, JPEG, HEIC, and HEIF.
## Replying in threads instead of the main channel
To minimize disruption in busy Slack channels, you can activate reply threading in the integration settings. This feature creates a thread for each incoming message, where the bot will respond. For a more targeted approach, enable the "Require Bot Mention for Replies" to only create threads when the bot is mentioned by name.
## Limitations
Standard Slack API limitations apply to the Slack integration in Botpress. These limitations include rate limits, message size restrictions, and other constraints imposed by the Slack platform. Ensure that your chatbot adheres to these limitations to maintain optimal performance and reliability.
More details are available in the [Slack API documentation](https://api.slack.com/apis/rate-limits).
+1
View File
@@ -0,0 +1 @@
<svg enable-background="new 0 0 2447.6 2452.5" viewBox="0 0 2447.6 2452.5" xmlns="http://www.w3.org/2000/svg"><g clip-rule="evenodd" fill-rule="evenodd"><path d="m897.4 0c-135.3.1-244.8 109.9-244.7 245.2-.1 135.3 109.5 245.1 244.8 245.2h244.8v-245.1c.1-135.3-109.5-245.1-244.9-245.3.1 0 .1 0 0 0m0 654h-652.6c-135.3.1-244.9 109.9-244.8 245.2-.2 135.3 109.4 245.1 244.7 245.3h652.7c135.3-.1 244.9-109.9 244.8-245.2.1-135.4-109.5-245.2-244.8-245.3z" fill="#36c5f0"/><path d="m2447.6 899.2c.1-135.3-109.5-245.1-244.8-245.2-135.3.1-244.9 109.9-244.8 245.2v245.3h244.8c135.3-.1 244.9-109.9 244.8-245.3zm-652.7 0v-654c.1-135.2-109.4-245-244.7-245.2-135.3.1-244.9 109.9-244.8 245.2v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.3z" fill="#2eb67d"/><path d="m1550.1 2452.5c135.3-.1 244.9-109.9 244.8-245.2.1-135.3-109.5-245.1-244.8-245.2h-244.8v245.2c-.1 135.2 109.5 245 244.8 245.2zm0-654.1h652.7c135.3-.1 244.9-109.9 244.8-245.2.2-135.3-109.4-245.1-244.7-245.3h-652.7c-135.3.1-244.9 109.9-244.8 245.2-.1 135.4 109.4 245.2 244.7 245.3z" fill="#ecb22e"/><path d="m0 1553.2c-.1 135.3 109.5 245.1 244.8 245.2 135.3-.1 244.9-109.9 244.8-245.2v-245.2h-244.8c-135.3.1-244.9 109.9-244.8 245.2zm652.7 0v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.2v-653.9c.2-135.3-109.4-245.1-244.7-245.3-135.4 0-244.9 109.8-244.8 245.1 0 0 0 .1 0 0" fill="#e01e5a"/></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

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