chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
@@ -0,0 +1,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
}
}
}