chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import { isOAuthWizardUrl } from '@botpress/common/src/oauth-wizard'
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import type { SlackEvent } from '@slack/types'
|
||||
import { safeParseBody } from 'src/misc/utils'
|
||||
import { oauthWizardHandler } from '../oauth-wizard'
|
||||
import { getAppCredentials } from '../slack-api/slack-manifest-client'
|
||||
import * as handlers from './handlers'
|
||||
import { handleInteractiveRequest, isInteractiveRequest } from './handlers/interactive-request'
|
||||
import { isOAuthCallback, handleOAuthCallback } from './handlers/oauth-callback'
|
||||
import { isUrlVerificationRequest, handleUrlVerificationRequest } from './handlers/url-verification'
|
||||
import { SlackEventSignatureValidator } from './signature-validator'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const handler: bp.IntegrationProps['handler'] = async ({ req, ctx, client, logger }) => {
|
||||
logger.forBot().debug('Handler received request from Slack with payload:', req.body)
|
||||
|
||||
if (isOAuthWizardUrl(req.path)) {
|
||||
return await oauthWizardHandler({ req, client, logger, ctx })
|
||||
}
|
||||
|
||||
if (isOAuthCallback(req)) {
|
||||
return await handleOAuthCallback({ req, client, logger, ctx })
|
||||
}
|
||||
|
||||
_verifyBodyIsPresent(req)
|
||||
|
||||
const parseRes = safeParseBody(req.body)
|
||||
|
||||
if (!parseRes.success) {
|
||||
const { error } = parseRes
|
||||
logger.forBot().error('could not parse the JSON', error)
|
||||
}
|
||||
const { data } = parseRes
|
||||
|
||||
if (isUrlVerificationRequest(data)) {
|
||||
logger.forBot().debug('Handler received request of type url_verification')
|
||||
return handleUrlVerificationRequest(data)
|
||||
}
|
||||
|
||||
await _verifyMessageIsProperlyAuthenticated({ req, client, logger, ctx })
|
||||
|
||||
if (isInteractiveRequest(req)) {
|
||||
return await handleInteractiveRequest({ req, client, logger, ctx })
|
||||
}
|
||||
|
||||
const event: SlackEvent = data.event
|
||||
logger.forBot().debug(`Handler received request of type ${data.event.type}`)
|
||||
|
||||
if (await _isEventProducedByBot({ client, ctx }, event)) {
|
||||
logger.forBot().debug('Ignoring event produced by the bot itself')
|
||||
return
|
||||
}
|
||||
|
||||
await _dispatchEvent({ client, ctx, logger, req }, event)
|
||||
}
|
||||
|
||||
function _verifyBodyIsPresent(req: sdk.Request): asserts req is sdk.Request & { body: string } {
|
||||
if (!req.body) {
|
||||
throw new sdk.RuntimeError('Handler received a request with an empty body')
|
||||
}
|
||||
}
|
||||
|
||||
const _verifyMessageIsProperlyAuthenticated = async ({ req, logger, ...props }: bp.HandlerProps) => {
|
||||
const signingSecret = await _getSigningSecret({ logger, ...props })
|
||||
const isSignatureValid = new SlackEventSignatureValidator(signingSecret, req, logger).isEventProperlyAuthenticated()
|
||||
|
||||
if (!isSignatureValid) {
|
||||
throw new sdk.RuntimeError('Handler received a request with an invalid signature')
|
||||
}
|
||||
}
|
||||
|
||||
const _isEventProducedByBot = async (
|
||||
{ client, ctx }: { client: bp.Client; ctx: bp.Context },
|
||||
event: SlackEvent
|
||||
): Promise<boolean> => {
|
||||
if ('bot_id' in event && event.bot_id) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
'user' in event &&
|
||||
event.user ===
|
||||
(await client.getState({ type: 'integration', name: 'oAuthCredentialsV2', id: ctx.integrationId })).state.payload
|
||||
.botUserId
|
||||
)
|
||||
}
|
||||
|
||||
const _dispatchEvent = async ({ client, ctx, logger }: bp.HandlerProps, slackEvent: SlackEvent) => {
|
||||
switch (slackEvent.type) {
|
||||
case 'message':
|
||||
return await handlers.messageReceived.handleEvent({ slackEvent, client, ctx, logger })
|
||||
|
||||
case 'reaction_added':
|
||||
return await handlers.reactionAdded.handleEvent({ slackEvent, client })
|
||||
|
||||
case 'reaction_removed':
|
||||
return await handlers.reactionRemoved.handleEvent({ slackEvent, client })
|
||||
|
||||
case 'team_join':
|
||||
return await handlers.memberJoinedWorkspace.handleEvent({ slackEvent, client })
|
||||
|
||||
case 'member_joined_channel':
|
||||
return await handlers.memberJoinedChannel.handleEvent({ slackEvent, client })
|
||||
|
||||
case 'member_left_channel':
|
||||
return await handlers.memberLeftChannel.handleEvent({ slackEvent, client })
|
||||
|
||||
case 'function_executed':
|
||||
return await handlers.functionExecuted.handleEvent({ slackEvent, client, logger })
|
||||
|
||||
default:
|
||||
logger.forBot().debug(`Ignoring unsupported event type ${slackEvent.type}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const _getSigningSecret = async ({ client, ctx }: bp.CommonHandlerProps): Promise<string> => {
|
||||
const appCreds = await getAppCredentials(client, ctx)
|
||||
if (appCreds.signingSecret) {
|
||||
return appCreds.signingSecret
|
||||
}
|
||||
return bp.secrets.SIGNING_SECRET
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { FunctionExecutedEvent } from '@slack/types'
|
||||
import type * as bp from '.botpress'
|
||||
|
||||
export const handleEvent = async ({
|
||||
slackEvent,
|
||||
logger,
|
||||
client,
|
||||
}: {
|
||||
slackEvent: FunctionExecutedEvent
|
||||
client: bp.Client
|
||||
logger: bp.Logger
|
||||
}) => {
|
||||
if (slackEvent.function.callback_id !== 'botpress-webook') {
|
||||
logger.forBot().debug(`Ignoring unsupported workflow function ${slackEvent.function.callback_id}`)
|
||||
return
|
||||
}
|
||||
|
||||
const inputValues = slackEvent.inputs.value
|
||||
const inputUserId = slackEvent.inputs.userId
|
||||
|
||||
if (!Array.isArray(inputValues)) {
|
||||
logger.forBot().debug(`Ignoring unsupported workflow function input value type ${typeof inputValues}`)
|
||||
return
|
||||
}
|
||||
|
||||
const userId = typeof inputUserId === 'string' ? inputUserId : undefined
|
||||
|
||||
if (inputUserId && typeof inputUserId !== 'string') {
|
||||
logger.forBot().debug(`Ignoring unsupported workflow function input userId type ${typeof inputUserId}`)
|
||||
return
|
||||
}
|
||||
|
||||
const inputValue = inputValues[0]
|
||||
|
||||
await client.createEvent({
|
||||
type: 'workflowWebhook',
|
||||
payload: {
|
||||
userId,
|
||||
value: inputValue,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export * as urlVerification from './url-verification'
|
||||
export * as memberJoinedChannel from './member-joined-channel'
|
||||
export * as memberLeftChannel from './member-left-channel'
|
||||
export * as messageReceived from './message-received'
|
||||
export * as reactionAdded from './reaction-added'
|
||||
export * as reactionRemoved from './reaction-removed'
|
||||
export * as memberJoinedWorkspace from './member-joined-workspace'
|
||||
export * as functionExecuted from './function-executed'
|
||||
@@ -0,0 +1,92 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import axios from 'axios'
|
||||
import { getBotpressConversationFromSlackThread, getBotpressUserFromSlackUser } from '../../misc/utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const isInteractiveRequest = (req: sdk.Request) =>
|
||||
req.body?.startsWith('payload=') || req.path === '/interactive'
|
||||
|
||||
export const handleInteractiveRequest = async ({ req, client, logger }: bp.HandlerProps) => {
|
||||
const body = _parseInteractiveBody(req)
|
||||
|
||||
const actionValue = await _respondInteractive(body)
|
||||
|
||||
if (body.type !== 'block_actions') {
|
||||
logger.forBot().error(`Interaction type ${body.type} received from Slack is not supported yet`)
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof actionValue !== 'string' || !actionValue?.length) {
|
||||
logger.forBot().debug('No action value was returned, so the message was ignored')
|
||||
return
|
||||
}
|
||||
|
||||
const { botpressUserId: userId } = await getBotpressUserFromSlackUser({ slackUserId: body.user.id }, client)
|
||||
const { botpressConversationId: conversationId } = await getBotpressConversationFromSlackThread(
|
||||
{ slackChannelId: body.channel.id },
|
||||
client
|
||||
)
|
||||
|
||||
await client.getOrCreateMessage({
|
||||
tags: {
|
||||
ts: body.message.ts,
|
||||
userId: body.user.id,
|
||||
channelId: body.channel.id,
|
||||
},
|
||||
type: 'text',
|
||||
payload: { text: actionValue },
|
||||
userId,
|
||||
conversationId,
|
||||
})
|
||||
}
|
||||
|
||||
type InteractiveBody = {
|
||||
response_url: string
|
||||
actions: {
|
||||
action_id?: string
|
||||
block_id?: string
|
||||
value?: string
|
||||
type: string
|
||||
selected_option?: { value: string }
|
||||
}[]
|
||||
type: string
|
||||
channel: {
|
||||
id: string
|
||||
}
|
||||
user: {
|
||||
id: string
|
||||
}
|
||||
message: {
|
||||
ts: string
|
||||
}
|
||||
}
|
||||
|
||||
const _parseInteractiveBody = (req: sdk.Request): InteractiveBody => {
|
||||
try {
|
||||
return JSON.parse(decodeURIComponent(req.body!).replace('payload=', ''))
|
||||
} catch (thrown: unknown) {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new sdk.RuntimeError('Body is invalid for interactive request', error)
|
||||
}
|
||||
}
|
||||
|
||||
const _respondInteractive = async (body: InteractiveBody): Promise<string> => {
|
||||
if (!body.actions.length) {
|
||||
throw new sdk.RuntimeError('No action in body')
|
||||
}
|
||||
|
||||
const action = body.actions[0]
|
||||
const text = action?.value || action?.selected_option?.value || action?.action_id
|
||||
if (text === undefined) {
|
||||
throw new sdk.RuntimeError('Action value cannot be undefined')
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.post(body.response_url, { text })
|
||||
|
||||
return text
|
||||
} catch (thrown: unknown) {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new sdk.RuntimeError('Error while responding to interactive request', error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { MemberJoinedChannelEvent } from '@slack/types'
|
||||
|
||||
import { getBotpressUserFromSlackUser, getBotpressConversationFromSlackThread } from '../../misc/utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const handleEvent = async ({
|
||||
slackEvent,
|
||||
client,
|
||||
}: {
|
||||
slackEvent: MemberJoinedChannelEvent
|
||||
client: bp.Client
|
||||
}) => {
|
||||
const { user: slackUserId, channel: slackChannelId, inviter: slackInviterId } = slackEvent
|
||||
const { botpressUserId } = await getBotpressUserFromSlackUser({ slackUserId }, client)
|
||||
const { botpressConversationId } = await getBotpressConversationFromSlackThread({ slackChannelId }, client)
|
||||
|
||||
let inviterBotpressUserId
|
||||
if (slackInviterId) {
|
||||
const inviter = await getBotpressUserFromSlackUser({ slackUserId: slackInviterId }, client)
|
||||
inviterBotpressUserId = inviter.botpressUserId
|
||||
}
|
||||
|
||||
await client.createEvent({
|
||||
type: 'memberJoinedChannel',
|
||||
payload: {
|
||||
botpressUserId,
|
||||
botpressConversationId,
|
||||
inviterBotpressUserId,
|
||||
targets: {
|
||||
slackUserId,
|
||||
slackChannelId,
|
||||
slackInviterId,
|
||||
},
|
||||
},
|
||||
conversationId: botpressConversationId,
|
||||
userId: botpressUserId,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { TeamJoinEvent } from '@slack/types'
|
||||
|
||||
import { getBotpressUserFromSlackUser } from '../../misc/utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const handleEvent = async ({ slackEvent, client }: { slackEvent: TeamJoinEvent; client: bp.Client }) => {
|
||||
const slackUserId = slackEvent.user.id
|
||||
const { botpressUserId } = await getBotpressUserFromSlackUser({ slackUserId }, client)
|
||||
|
||||
await client.createEvent({
|
||||
type: 'memberJoinedWorkspace',
|
||||
payload: {
|
||||
userId: botpressUserId,
|
||||
target: {
|
||||
userId: slackUserId,
|
||||
userName: slackEvent.user.name,
|
||||
userRealName: slackEvent.user.real_name,
|
||||
userDisplayName: slackEvent.user.profile.display_name,
|
||||
},
|
||||
},
|
||||
userId: botpressUserId,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { MemberLeftChannelEvent } from '@slack/types'
|
||||
|
||||
import { getBotpressUserFromSlackUser, getBotpressConversationFromSlackThread } from '../../misc/utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const handleEvent = async ({
|
||||
slackEvent,
|
||||
client,
|
||||
}: {
|
||||
slackEvent: MemberLeftChannelEvent
|
||||
client: bp.Client
|
||||
}) => {
|
||||
const { user: slackUserId, channel: slackChannelId } = slackEvent
|
||||
const { botpressUserId } = await getBotpressUserFromSlackUser({ slackUserId }, client)
|
||||
const { botpressConversationId } = await getBotpressConversationFromSlackThread({ slackChannelId }, client)
|
||||
|
||||
await client.createEvent({
|
||||
type: 'memberLeftChannel',
|
||||
payload: {
|
||||
botpressUserId,
|
||||
botpressConversationId,
|
||||
targets: {
|
||||
slackUserId,
|
||||
slackChannelId,
|
||||
},
|
||||
},
|
||||
conversationId: botpressConversationId,
|
||||
userId: botpressUserId,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
import { slackToMarkdown } from '@bpinternal/slackdown'
|
||||
import {
|
||||
ActionsBlockElement,
|
||||
AllMessageEvents,
|
||||
ContextBlockElement,
|
||||
FileShareMessageEvent,
|
||||
GenericMessageEvent,
|
||||
RichTextBlockElement,
|
||||
RichTextElement,
|
||||
RichTextSection,
|
||||
} from '@slack/types'
|
||||
import { textSchema } from 'definitions/channels/text-input-schema'
|
||||
import {
|
||||
getBotpressConversationFromSlackThread,
|
||||
getBotpressUserFromSlackUser,
|
||||
updateBotpressUserFromSlackUser,
|
||||
} from 'src/misc/utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type Mention = NonNullable<z.infer<typeof textSchema>['mentions']>[number]
|
||||
|
||||
type BlocItem =
|
||||
| bp.channels.channel.bloc.Bloc['items'][number]
|
||||
| {
|
||||
type: 'text'
|
||||
payload: {
|
||||
mentions: Mention[]
|
||||
text: string
|
||||
}
|
||||
}
|
||||
|
||||
type MessageTag = keyof bp.ClientRequests['getOrCreateMessage']['tags']
|
||||
|
||||
export type HandleEventProps = {
|
||||
slackEvent: AllMessageEvents
|
||||
client: bp.Client
|
||||
ctx: bp.Context
|
||||
logger: bp.Logger
|
||||
}
|
||||
|
||||
export const handleEvent = async (props: HandleEventProps) => {
|
||||
const { slackEvent, client, ctx, logger } = props
|
||||
|
||||
// do not handle notification-type messages such as channel_join, channel_leave, etc:
|
||||
if (slackEvent.subtype && slackEvent.subtype !== 'file_share') {
|
||||
return
|
||||
}
|
||||
|
||||
const { botpressUser } = await getBotpressUserFromSlackUser({ slackUserId: slackEvent.user }, client)
|
||||
await updateBotpressUserFromSlackUser(slackEvent.user, botpressUser, client, ctx, logger)
|
||||
|
||||
const mentionsBot = await _isBotMentionedInMessage({ slackEvent, client, ctx })
|
||||
const isSentInChannel = !slackEvent.thread_ts
|
||||
const replyLocation = ctx.configuration.replyBehaviour?.location ?? 'channel'
|
||||
const replyOnlyOnBotMention = ctx.configuration.replyBehaviour?.onlyOnBotMention ?? false
|
||||
|
||||
if (replyOnlyOnBotMention && !mentionsBot) {
|
||||
logger.forBot().warn('Message was not sent because the bot was not mentioned')
|
||||
return
|
||||
}
|
||||
|
||||
const shouldRespondInChannel =
|
||||
isSentInChannel && (replyLocation === 'channel' || replyLocation === 'channelAndThread')
|
||||
const shouldRespondInThread = !isSentInChannel || replyLocation === 'thread' || replyLocation === 'channelAndThread'
|
||||
|
||||
if (shouldRespondInChannel) {
|
||||
const { botpressConversation } = await getBotpressConversationFromSlackThread(
|
||||
{ slackChannelId: slackEvent.channel, slackThreadId: undefined },
|
||||
client
|
||||
)
|
||||
|
||||
await _sendMessage({
|
||||
botpressConversation,
|
||||
botpressUser,
|
||||
tags: {
|
||||
ts: slackEvent.ts,
|
||||
userId: slackEvent.user,
|
||||
channelId: slackEvent.channel,
|
||||
mentionsBot: mentionsBot ? 'true' : undefined,
|
||||
forkedToThread: 'false',
|
||||
},
|
||||
discriminateByTags: ['ts', 'channelId'],
|
||||
slackEvent,
|
||||
client,
|
||||
ctx,
|
||||
logger,
|
||||
})
|
||||
}
|
||||
|
||||
if (shouldRespondInThread) {
|
||||
const threadTs = slackEvent.thread_ts ?? slackEvent.ts
|
||||
|
||||
const { conversation: threadConversation } = await client.getOrCreateConversation({
|
||||
channel: 'thread',
|
||||
tags: { id: slackEvent.channel, thread: threadTs, isBotReplyThread: 'true' },
|
||||
discriminateByTags: ['id', 'thread'],
|
||||
})
|
||||
|
||||
await _sendMessage({
|
||||
botpressConversation: threadConversation,
|
||||
botpressUser,
|
||||
tags: {
|
||||
ts: slackEvent.ts,
|
||||
userId: slackEvent.user,
|
||||
channelId: slackEvent.channel,
|
||||
mentionsBot: mentionsBot ? 'true' : undefined,
|
||||
forkedToThread: isSentInChannel ? 'true' : 'false',
|
||||
},
|
||||
discriminateByTags: ['ts', 'channelId', 'forkedToThread'],
|
||||
slackEvent,
|
||||
client,
|
||||
ctx,
|
||||
logger,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type _SendMessageProps = HandleEventProps & {
|
||||
botpressConversation: { id: string }
|
||||
botpressUser: { id: string }
|
||||
tags: Record<MessageTag, string | undefined>
|
||||
discriminateByTags: MessageTag[] | undefined
|
||||
}
|
||||
|
||||
const _sendMessage = async (props: _SendMessageProps) => {
|
||||
const { slackEvent, client, ctx, logger, botpressConversation, botpressUser, tags, discriminateByTags } = props
|
||||
|
||||
if (slackEvent.subtype === 'file_share') {
|
||||
await _getOrCreateMessageFromFiles({
|
||||
ctx,
|
||||
botpressConversation,
|
||||
botpressUser,
|
||||
slackEvent,
|
||||
client,
|
||||
logger,
|
||||
tags,
|
||||
discriminateByTags,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (slackEvent.subtype) {
|
||||
return
|
||||
}
|
||||
const text = _parseSlackEventText(slackEvent)
|
||||
if (!text) {
|
||||
logger.forBot().debug('No text was received, so the message was ignored')
|
||||
return
|
||||
}
|
||||
|
||||
await client.getOrCreateMessage({
|
||||
type: 'text',
|
||||
payload: await _getTextPayloadFromSlackEvent(slackEvent, client, ctx, logger),
|
||||
userId: botpressUser.id,
|
||||
conversationId: botpressConversation.id,
|
||||
tags,
|
||||
discriminateByTags,
|
||||
})
|
||||
}
|
||||
|
||||
const _isBotMentionedInMessage = async ({
|
||||
slackEvent,
|
||||
client,
|
||||
ctx,
|
||||
}: {
|
||||
slackEvent: AllMessageEvents
|
||||
client: bp.Client
|
||||
ctx: bp.Context
|
||||
}) => {
|
||||
if (slackEvent.subtype || !slackEvent.text) {
|
||||
return false
|
||||
}
|
||||
|
||||
const slackBotId = await _getSlackBotIdFromStates(client, ctx)
|
||||
|
||||
if (!slackBotId) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
slackEvent.text.includes(`<@${slackBotId}>`) ||
|
||||
(slackEvent.blocks?.some(
|
||||
(block) =>
|
||||
'elements' in block &&
|
||||
block.elements.some(
|
||||
(element) =>
|
||||
'elements' in element &&
|
||||
element.elements.some((subElement) => subElement.type === 'user' && subElement.user_id === slackBotId)
|
||||
)
|
||||
) ??
|
||||
false)
|
||||
)
|
||||
}
|
||||
|
||||
const _getSlackBotIdFromStates = async (client: bp.Client, ctx: bp.Context) => {
|
||||
try {
|
||||
const { state } = await client.getState({
|
||||
type: 'integration',
|
||||
name: 'oAuthCredentialsV2',
|
||||
id: ctx.integrationId,
|
||||
})
|
||||
return state.payload.botUserId
|
||||
} catch {}
|
||||
return
|
||||
}
|
||||
|
||||
const _getOrCreateMessageFromFiles = async ({
|
||||
ctx,
|
||||
botpressUser,
|
||||
botpressConversation,
|
||||
slackEvent,
|
||||
client,
|
||||
logger,
|
||||
tags,
|
||||
discriminateByTags,
|
||||
}: _SendMessageProps & {
|
||||
slackEvent: FileShareMessageEvent
|
||||
}) => {
|
||||
const parsedEvent = _parseFileSlackEvent(slackEvent)
|
||||
if (!parsedEvent.type) {
|
||||
return
|
||||
}
|
||||
|
||||
const { id: userId } = botpressUser
|
||||
const { id: conversationId } = botpressConversation
|
||||
|
||||
if (parsedEvent.type === 'file') {
|
||||
const { payload: file } = parsedEvent
|
||||
|
||||
const blocItem = _parseSlackFile(logger, file)
|
||||
if (!blocItem) {
|
||||
return
|
||||
}
|
||||
|
||||
await client.getOrCreateMessage({
|
||||
tags,
|
||||
userId,
|
||||
conversationId,
|
||||
discriminateByTags,
|
||||
...blocItem,
|
||||
} as bp.ClientRequests['getOrCreateMessage'])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const items: BlocItem[] = []
|
||||
|
||||
if (slackEvent.text) {
|
||||
items.push({ type: 'text', payload: await _getTextPayloadFromSlackEvent(slackEvent, client, ctx, logger) })
|
||||
}
|
||||
|
||||
for (const file of parsedEvent.items) {
|
||||
const item = _parseSlackFile(logger, file)
|
||||
if (item) {
|
||||
items.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
await client.getOrCreateMessage({
|
||||
tags,
|
||||
userId,
|
||||
conversationId,
|
||||
discriminateByTags,
|
||||
type: 'bloc',
|
||||
payload: { items },
|
||||
})
|
||||
}
|
||||
|
||||
const _parseSlackFile = (logger: bp.Logger, file: File): BlocItem | null => {
|
||||
const fileType = file.mimetype.split('/')[0]
|
||||
if (!fileType) {
|
||||
logger.forBot().info('File of type', fileType, 'is not yet supported.')
|
||||
return null
|
||||
}
|
||||
|
||||
if (!file.permalink_public) {
|
||||
logger.forBot().info('File had no public permalink')
|
||||
return null
|
||||
}
|
||||
|
||||
switch (fileType) {
|
||||
case 'image':
|
||||
return { type: fileType, payload: { imageUrl: file.permalink_public } }
|
||||
|
||||
case 'audio':
|
||||
return { type: fileType, payload: { audioUrl: file.permalink_public } }
|
||||
|
||||
case 'video':
|
||||
return { type: fileType, payload: { videoUrl: file.permalink_public } }
|
||||
|
||||
case 'file':
|
||||
return { type: fileType, payload: { fileUrl: file.permalink_public } }
|
||||
|
||||
case 'text':
|
||||
return { type: 'file', payload: { fileUrl: file.permalink_public } }
|
||||
|
||||
default:
|
||||
logger.forBot().info('File of type', fileType, 'is not yet supported.')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const _parseSlackEventText = (slackEvent: GenericMessageEvent | FileShareMessageEvent): string | null => {
|
||||
if (slackEvent.text === undefined || typeof slackEvent.text !== 'string' || !slackEvent.text?.length) {
|
||||
return null
|
||||
}
|
||||
return slackEvent.text
|
||||
}
|
||||
|
||||
type File = NonNullable<FileShareMessageEvent['files']>[number]
|
||||
type _ParsedFileSlackEvent =
|
||||
| {
|
||||
type: null
|
||||
}
|
||||
| {
|
||||
type: 'file'
|
||||
payload: File
|
||||
}
|
||||
| {
|
||||
type: 'bloc'
|
||||
text: string | null
|
||||
items: File[]
|
||||
}
|
||||
const _parseFileSlackEvent = (slackEvent: FileShareMessageEvent): _ParsedFileSlackEvent => {
|
||||
if (!slackEvent.files) {
|
||||
return { type: null }
|
||||
}
|
||||
|
||||
const [file] = slackEvent.files
|
||||
if (!file) {
|
||||
return { type: null }
|
||||
}
|
||||
|
||||
const text = _parseSlackEventText(slackEvent)
|
||||
if (slackEvent.files.length === 1 && !text) {
|
||||
return { type: 'file', payload: file }
|
||||
}
|
||||
|
||||
return { type: 'bloc', text, items: slackEvent.files }
|
||||
}
|
||||
|
||||
const _getTextPayloadFromSlackEvent = async (
|
||||
slackEvent: GenericMessageEvent | FileShareMessageEvent,
|
||||
client: bp.Client,
|
||||
ctx: bp.Context,
|
||||
logger: bp.Logger
|
||||
): Promise<{
|
||||
text: string
|
||||
mentions: Mention[]
|
||||
}> => {
|
||||
if (!slackEvent.text) {
|
||||
return { text: '', mentions: [] }
|
||||
}
|
||||
let text = slackEvent.text
|
||||
const mentions: Mention[] = []
|
||||
const blocks = slackEvent.blocks ?? []
|
||||
|
||||
type BlockElement = ContextBlockElement | ActionsBlockElement | RichTextBlockElement
|
||||
type BlockSubElement = RichTextSection | RichTextElement
|
||||
const userElements = blocks
|
||||
.flatMap((block): BlockElement[] => ('elements' in block ? (block.elements as BlockElement[]) : []))
|
||||
.flatMap((element): BlockSubElement[] => ('elements' in element ? element.elements : []))
|
||||
.filter((subElement) => subElement.type === 'user')
|
||||
|
||||
for (const userElement of userElements) {
|
||||
const { botpressUser } = await getBotpressUserFromSlackUser({ slackUserId: userElement.user_id }, client)
|
||||
await updateBotpressUserFromSlackUser(userElement.user_id, botpressUser, client, ctx, logger)
|
||||
if (!botpressUser.name) {
|
||||
continue
|
||||
}
|
||||
text = text.replace(userElement.user_id, botpressUser.name)
|
||||
mentions.push({ type: userElement.type, start: 1, end: 1, user: { id: botpressUser.id, name: botpressUser.name } })
|
||||
}
|
||||
|
||||
for (const mention of mentions) {
|
||||
if (!mention.user.name) {
|
||||
continue
|
||||
}
|
||||
mention.start = text.search(mention.user.name)
|
||||
mention.end = mention.start + mention.user.name.length
|
||||
}
|
||||
text = slackToMarkdown(text)
|
||||
|
||||
return { text, mentions }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import { SlackClient } from 'src/slack-api'
|
||||
import { getAppCredentials } from 'src/slack-api/slack-manifest-client'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const isOAuthCallback = (req: sdk.Request): req is sdk.Request & { path: '/oauth' } => req.path === '/oauth'
|
||||
|
||||
export const handleOAuthCallback = async ({ req, client, ctx, logger }: bp.HandlerProps) => {
|
||||
logger.forBot().debug('OAuth callback handled in webhook handler, redirecting to end step')
|
||||
const query = new URLSearchParams(req.query)
|
||||
const code = query.get('code')
|
||||
|
||||
if (!code || typeof code !== 'string') {
|
||||
throw new Error('Handler received an empty code')
|
||||
}
|
||||
|
||||
const slackClient = await SlackClient.createFromAuthorizationCode({ client, ctx, logger, authorizationCode: code })
|
||||
const appCreds = await getAppCredentials(client, ctx)
|
||||
const identifier = appCreds.clientId ? slackClient.getBotUserId() : slackClient.getTeamId()
|
||||
|
||||
await client.configureIntegration({ identifier })
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ReactionAddedEvent } from '@slack/types'
|
||||
|
||||
import {
|
||||
getMessageFromSlackEvent,
|
||||
getBotpressConversationFromSlackThread,
|
||||
getBotpressUserFromSlackUser,
|
||||
} from '../../misc/utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const handleEvent = async ({ slackEvent, client }: { slackEvent: ReactionAddedEvent; client: bp.Client }) => {
|
||||
const { botpressConversationId } = await getBotpressConversationFromSlackThread(
|
||||
{ slackChannelId: slackEvent.item.channel },
|
||||
client
|
||||
)
|
||||
const { botpressUserId } = await getBotpressUserFromSlackUser({ slackUserId: slackEvent.user }, client)
|
||||
|
||||
const message = await getMessageFromSlackEvent(client, slackEvent)
|
||||
|
||||
await client.createEvent({
|
||||
type: 'reactionAdded',
|
||||
payload: {
|
||||
reaction: slackEvent.reaction,
|
||||
targets: {
|
||||
dm: { id: slackEvent.user },
|
||||
thread: 'ts' in slackEvent.item ? { id: slackEvent.item.channel, thread: slackEvent.item.ts } : undefined,
|
||||
channel: { id: slackEvent.item.channel },
|
||||
},
|
||||
userId: botpressUserId,
|
||||
conversationId: botpressConversationId,
|
||||
},
|
||||
userId: botpressUserId,
|
||||
conversationId: botpressConversationId,
|
||||
messageId: message?.id,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ReactionRemovedEvent } from '@slack/types'
|
||||
|
||||
import {
|
||||
getMessageFromSlackEvent,
|
||||
getBotpressConversationFromSlackThread,
|
||||
getBotpressUserFromSlackUser,
|
||||
} from '../../misc/utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const handleEvent = async ({ slackEvent, client }: { slackEvent: ReactionRemovedEvent; client: bp.Client }) => {
|
||||
if (slackEvent.item.type !== 'message') {
|
||||
return
|
||||
}
|
||||
|
||||
const { botpressUserId } = await getBotpressUserFromSlackUser({ slackUserId: slackEvent.user }, client)
|
||||
const { botpressConversationId } = await getBotpressConversationFromSlackThread(
|
||||
{ slackChannelId: slackEvent.item.channel },
|
||||
client
|
||||
)
|
||||
|
||||
const message = await getMessageFromSlackEvent(client, slackEvent)
|
||||
|
||||
await client.createEvent({
|
||||
type: 'reactionRemoved',
|
||||
payload: {
|
||||
reaction: slackEvent.reaction,
|
||||
targets: {
|
||||
dm: { id: slackEvent.user },
|
||||
thread: { id: slackEvent.item.channel, thread: slackEvent.item.ts },
|
||||
channel: { id: slackEvent.item.channel },
|
||||
},
|
||||
userId: botpressUserId,
|
||||
conversationId: botpressConversationId,
|
||||
},
|
||||
conversationId: botpressConversationId,
|
||||
userId: botpressUserId,
|
||||
messageId: message?.id,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
|
||||
const URL_VERIFICATION_SCHEMA = sdk.z.object({
|
||||
type: sdk.z.literal('url_verification'),
|
||||
challenge: sdk.z.string(),
|
||||
})
|
||||
|
||||
export const isUrlVerificationRequest = (data: unknown): data is sdk.z.infer<typeof URL_VERIFICATION_SCHEMA> =>
|
||||
URL_VERIFICATION_SCHEMA.safeParse(data).success
|
||||
|
||||
export const handleUrlVerificationRequest = (data: sdk.z.infer<typeof URL_VERIFICATION_SCHEMA>) => ({
|
||||
body: JSON.stringify({ challenge: data.challenge }),
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export { handler } from './handler-dispatcher'
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import * as crypto from 'crypto'
|
||||
import { SlackEventSignatureValidator } from './signature-validator'
|
||||
|
||||
const VALID_SIGNING_SECRET = 'valid-signing-secret'
|
||||
const INVALID_SIGNING_SECRET = 'invalid-signing-secret'
|
||||
|
||||
const mockedLogger = { forBot: () => ({ error: console.error }) } as any
|
||||
|
||||
const generateSlackSignature = (secret: string, timestamp: string, body: any) => {
|
||||
const sigBasestring = `v0:${timestamp}:${body}`
|
||||
return 'v0=' + crypto.createHmac('sha256', secret).update(sigBasestring).digest('hex')
|
||||
}
|
||||
|
||||
describe('SlackEventSignatureValidator', () => {
|
||||
it('validates a legitimate Slack request', () => {
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString()
|
||||
const body = {}
|
||||
const signature = generateSlackSignature(VALID_SIGNING_SECRET, timestamp, body)
|
||||
|
||||
const mockRequest = {
|
||||
headers: {
|
||||
'x-slack-request-timestamp': timestamp,
|
||||
'x-slack-signature': signature,
|
||||
},
|
||||
body,
|
||||
}
|
||||
|
||||
expect(
|
||||
new SlackEventSignatureValidator(
|
||||
VALID_SIGNING_SECRET,
|
||||
mockRequest as any,
|
||||
mockedLogger
|
||||
).isEventProperlyAuthenticated()
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('invalidates a 6 min old legitimate Slack request', () => {
|
||||
const timestamp = (Math.floor(Date.now() / 1000) - 60 * 6).toString()
|
||||
const body = {}
|
||||
const signature = generateSlackSignature(VALID_SIGNING_SECRET, timestamp, body)
|
||||
|
||||
const mockRequest = {
|
||||
headers: {
|
||||
'x-slack-request-timestamp': timestamp,
|
||||
'x-slack-signature': signature,
|
||||
},
|
||||
body,
|
||||
}
|
||||
|
||||
expect(
|
||||
new SlackEventSignatureValidator(
|
||||
VALID_SIGNING_SECRET,
|
||||
mockRequest as any,
|
||||
mockedLogger
|
||||
).isEventProperlyAuthenticated()
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('invalidates a request with an incorrect signature', () => {
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString()
|
||||
const body = {}
|
||||
const invalidSignature = generateSlackSignature(INVALID_SIGNING_SECRET, timestamp, body)
|
||||
|
||||
const mockRequest = {
|
||||
headers: {
|
||||
'x-slack-request-timestamp': timestamp,
|
||||
'x-slack-signature': invalidSignature,
|
||||
},
|
||||
body,
|
||||
}
|
||||
|
||||
expect(
|
||||
new SlackEventSignatureValidator(
|
||||
VALID_SIGNING_SECRET,
|
||||
mockRequest as any,
|
||||
mockedLogger
|
||||
).isEventProperlyAuthenticated()
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('invalidates a request with a signature of a different length', () => {
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString()
|
||||
const body = {}
|
||||
|
||||
const mockRequest = {
|
||||
headers: {
|
||||
'x-slack-request-timestamp': timestamp,
|
||||
'x-slack-signature': '90f4j8032j04392jf043fj9f4230j2f4',
|
||||
},
|
||||
body,
|
||||
}
|
||||
|
||||
expect(
|
||||
new SlackEventSignatureValidator(
|
||||
VALID_SIGNING_SECRET,
|
||||
mockRequest as any,
|
||||
mockedLogger
|
||||
).isEventProperlyAuthenticated()
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import * as crypto from 'crypto'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export class SlackEventSignatureValidator {
|
||||
public constructor(
|
||||
private readonly _signingSecret: string,
|
||||
private readonly _request: sdk.Request,
|
||||
private readonly _logger: bp.Logger
|
||||
) {}
|
||||
|
||||
public isEventProperlyAuthenticated(): boolean {
|
||||
return this._areHeadersPresent() && this._isTimestampWithinAcceptableRange() && this._isSignatureValid()
|
||||
}
|
||||
|
||||
private _areHeadersPresent(): boolean {
|
||||
const timestamp = this._request.headers['x-slack-request-timestamp']
|
||||
const slackSignature = this._request.headers['x-slack-signature']
|
||||
|
||||
if (!timestamp || !slackSignature) {
|
||||
this._logger.forBot().error('Request signature verification failed: missing timestamp or signature')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private _isTimestampWithinAcceptableRange(): boolean {
|
||||
const fiveMinutesAgo = Math.floor(Date.now() / 1000) - 60 * 5
|
||||
const timestamp = this._request.headers['x-slack-request-timestamp'] as string
|
||||
|
||||
if (parseInt(timestamp) < fiveMinutesAgo) {
|
||||
this._logger.forBot().error('Request signature verification failed: timestamp is too old')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private _isSignatureValid(): boolean {
|
||||
const sigBasestring = `v0:${this._request.headers['x-slack-request-timestamp']}:${this._request.body}`
|
||||
const mySignature = 'v0=' + crypto.createHmac('sha256', this._signingSecret).update(sigBasestring).digest('hex')
|
||||
|
||||
try {
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(mySignature, 'utf8'),
|
||||
Buffer.from(this._request.headers['x-slack-signature'] as string, 'utf8')
|
||||
)
|
||||
} catch {
|
||||
this._logger.forBot().error('An error occurred while verifying the request signature')
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user