chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import { createActionWrapper } from '@botpress/common'
|
||||
import { wrapAsyncFnWithTryCatch, SlackClient } from '../slack-api'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const wrapActionAndInjectSlackClient: typeof _wrapActionAndInjectTools = (meta, actionImpl) =>
|
||||
_wrapActionAndInjectTools(meta, (props) =>
|
||||
wrapAsyncFnWithTryCatch(() => {
|
||||
props.logger.forBot().debug(`Running action "${meta.actionName}" [bot id: ${props.ctx.botId}]`)
|
||||
|
||||
return actionImpl(props as Parameters<typeof actionImpl>[0], props.input)
|
||||
}, `Action Error: ${meta.errorMessage}`)()
|
||||
)
|
||||
|
||||
const _wrapActionAndInjectTools = createActionWrapper<bp.IntegrationProps>()({
|
||||
toolFactories: {
|
||||
slackClient: (props) => SlackClient.createFromStates(props),
|
||||
},
|
||||
extraMetadata: {} as {
|
||||
errorMessage: string
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import { wrapActionAndInjectSlackClient } from 'src/actions/action-wrapper'
|
||||
import { retrieveChannelAndMessageTs } from './utils/message-utils'
|
||||
|
||||
export const addReaction = wrapActionAndInjectSlackClient(
|
||||
{ actionName: 'addReaction', errorMessage: 'Failed to add reaction' },
|
||||
async ({ client, logger, slackClient }, { messageId, name }) => {
|
||||
if (!messageId) {
|
||||
throw new sdk.RuntimeError('Missing Botpress message ID')
|
||||
}
|
||||
|
||||
const { channel, ts } = await retrieveChannelAndMessageTs({
|
||||
client,
|
||||
messageId,
|
||||
})
|
||||
|
||||
logger.forBot().debug('Sending reaction to Slack')
|
||||
|
||||
await slackClient.addReactionToMessage({
|
||||
channelId: channel,
|
||||
messageTs: ts,
|
||||
reactionName: name,
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
import Fuse from 'fuse.js'
|
||||
import { wrapActionAndInjectSlackClient } from 'src/actions/action-wrapper'
|
||||
import { Target } from '../../definitions/actions'
|
||||
|
||||
const fuse = new Fuse<Target>([], {
|
||||
shouldSort: true,
|
||||
threshold: 0.3,
|
||||
minMatchCharLength: 2,
|
||||
isCaseSensitive: false,
|
||||
includeMatches: true,
|
||||
findAllMatches: true,
|
||||
useExtendedSearch: true,
|
||||
ignoreLocation: true,
|
||||
keys: ['displayName'],
|
||||
})
|
||||
|
||||
export const findTarget = wrapActionAndInjectSlackClient(
|
||||
{ actionName: 'findTarget', errorMessage: 'Failed to find any target' },
|
||||
async ({ slackClient }, { channel, query }) => {
|
||||
const targets: (Target & Record<string, unknown>)[] =
|
||||
channel === 'dm'
|
||||
? await slackClient
|
||||
.enumerateAllMembers()
|
||||
.collect()
|
||||
.then((members) =>
|
||||
members.map((member) => ({
|
||||
// TODO: perform mapping in the slack client directly; we don't want
|
||||
// to expose raw slack member objects outside of the custom
|
||||
// slack client
|
||||
displayName: member.name!,
|
||||
name: member.profile?.real_name ?? '',
|
||||
email: member.profile?.email ?? '',
|
||||
tags: { id: member.id! },
|
||||
channel: 'dm',
|
||||
}))
|
||||
)
|
||||
: await slackClient
|
||||
.enumerateAllPublicChannels()
|
||||
.collect()
|
||||
.then((channels) =>
|
||||
channels.map((channel) => ({
|
||||
displayName: channel.name!,
|
||||
tags: { id: channel.id! },
|
||||
channel: 'channel',
|
||||
}))
|
||||
)
|
||||
|
||||
fuse.setCollection(targets)
|
||||
const filteredTargets: Target[] = fuse.search<Target>(query).map((x) => x.item)
|
||||
|
||||
return {
|
||||
targets: filteredTargets,
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
import { wrapActionAndInjectSlackClient } from 'src/actions/action-wrapper'
|
||||
|
||||
export const getChannelsInfo = wrapActionAndInjectSlackClient(
|
||||
{ actionName: 'getChannelsInfo', errorMessage: 'Failed to get channels info' },
|
||||
async ({ slackClient }, { includeArchived, includePrivate, includeDm, cursor }) => {
|
||||
const { channels, nextCursor } = await slackClient.getChannelsInfo({
|
||||
includeArchived: includeArchived ?? false,
|
||||
includePrivate: includePrivate ?? false,
|
||||
includeDm: includeDm ?? false,
|
||||
cursor,
|
||||
})
|
||||
|
||||
return {
|
||||
channels,
|
||||
nextCursor: nextCursor ?? '',
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
import { RuntimeError } from '@botpress/client'
|
||||
import { wrapActionAndInjectSlackClient } from 'src/actions/action-wrapper'
|
||||
|
||||
export const getOrCreateChannelConversation = wrapActionAndInjectSlackClient(
|
||||
{ actionName: 'getOrCreateChannelConversation', errorMessage: 'Failed to get or create channel conversation' },
|
||||
async ({ client }, { conversation: { channelId } }) => {
|
||||
if (!channelId) {
|
||||
throw new RuntimeError('channelId must be provided')
|
||||
}
|
||||
|
||||
const { conversation } = await client.getOrCreateConversation({
|
||||
channel: 'channel',
|
||||
tags: { id: channelId },
|
||||
})
|
||||
|
||||
return { conversationId: conversation.id }
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
import { wrapActionAndInjectSlackClient } from 'src/actions/action-wrapper'
|
||||
|
||||
export const getUserProfile = wrapActionAndInjectSlackClient(
|
||||
{ actionName: 'getUserProfile', errorMessage: 'Failed to retrieve user profile' },
|
||||
async ({ slackClient }, { userId }) => {
|
||||
const userProfile = await slackClient.getUserProfile({ userId })
|
||||
|
||||
return {
|
||||
firstName: userProfile?.first_name,
|
||||
lastName: userProfile?.last_name,
|
||||
email: userProfile?.email,
|
||||
displayName: userProfile?.display_name,
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
import { addReaction } from './add-reaction'
|
||||
import { findTarget } from './find-target'
|
||||
import { getChannelsInfo } from './get-channels-info'
|
||||
import { getOrCreateChannelConversation } from './get-or-create-channel-conversation'
|
||||
import { getUserProfile } from './get-user-profile'
|
||||
import { retrieveMessage } from './retrieve-message'
|
||||
import { syncMembers } from './sync-members'
|
||||
import { startTypingIndicator, stopTypingIndicator } from './typing-indicator'
|
||||
import { updateChannelTopic } from './update-channel-topic'
|
||||
import type * as bp from '.botpress'
|
||||
|
||||
export default {
|
||||
addReaction,
|
||||
findTarget,
|
||||
getChannelsInfo,
|
||||
getOrCreateChannelConversation,
|
||||
retrieveMessage,
|
||||
syncMembers,
|
||||
updateChannelTopic,
|
||||
startTypingIndicator,
|
||||
stopTypingIndicator,
|
||||
getUserProfile,
|
||||
} satisfies bp.IntegrationProps['actions']
|
||||
@@ -0,0 +1,20 @@
|
||||
import { wrapActionAndInjectSlackClient } from 'src/actions/action-wrapper'
|
||||
|
||||
export const retrieveMessage = wrapActionAndInjectSlackClient(
|
||||
{ actionName: 'retrieveMessage', errorMessage: 'Failed to retrieve message' },
|
||||
async ({ logger, slackClient }, { ts, channel }) => {
|
||||
const message = await slackClient.retrieveMessage({ channel, messageTs: ts })
|
||||
|
||||
if (!message.type || !message.ts || !message.user || !message.text) {
|
||||
logger.forBot().error('Message is missing required fields')
|
||||
throw new Error('Message is missing required fields')
|
||||
}
|
||||
|
||||
return {
|
||||
type: message.type,
|
||||
ts: message.ts,
|
||||
user: message.user,
|
||||
text: message.text,
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,99 @@
|
||||
import { User } from '@botpress/client'
|
||||
import { createOrUpdateUser } from '@botpress/common'
|
||||
import { isApiError } from '@botpress/sdk'
|
||||
import { Member } from '@slack/web-api/dist/response/UsersListResponse'
|
||||
import { wrapActionAndInjectSlackClient } from 'src/actions/action-wrapper'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const syncMembers = wrapActionAndInjectSlackClient(
|
||||
{ actionName: 'syncMembers', errorMessage: 'Failed to sync Slack users' },
|
||||
async ({ logger, ctx, client, slackClient }, {}) => {
|
||||
let { usersLastSyncTs } = await _getSyncState(client, ctx)
|
||||
logger.forBot().debug(`Last sync timestamp for Slack users: ${usersLastSyncTs}`)
|
||||
|
||||
const unsortedMembers: Member[] = await slackClient.enumerateAllMembers().collect()
|
||||
|
||||
const recentlyUpdatedMembers = unsortedMembers
|
||||
.filter((x) => (x.updated ?? 0) > (usersLastSyncTs ?? -1))
|
||||
.sort((a, b) => (a.updated ?? 0) - (b.updated ?? 0))
|
||||
|
||||
logger
|
||||
.forBot()
|
||||
.debug(`Found ${recentlyUpdatedMembers.length}/${unsortedMembers.length} members to sync in Slack workspace`)
|
||||
|
||||
for (const slackMember of recentlyUpdatedMembers) {
|
||||
logger
|
||||
.forBot()
|
||||
.debug('Syncing Slack user to Botpress...', { user: slackMember.name, updated: slackMember.updated })
|
||||
try {
|
||||
const user = await syncSlackUserToBotpressUser(slackMember, client)
|
||||
logger.forBot().debug(`Synced Slack user ${user.name} (${user.id})`)
|
||||
usersLastSyncTs = Math.max(usersLastSyncTs ?? 0, slackMember.updated ?? 0)
|
||||
await _saveSyncState(client, ctx, { usersLastSyncTs })
|
||||
} catch (err) {
|
||||
logger.forBot().error(`Failed to sync Slack user ${slackMember.name}`, err)
|
||||
}
|
||||
}
|
||||
|
||||
logger.forBot().debug(`Synced ${recentlyUpdatedMembers.length} Slack users to Botpress`)
|
||||
|
||||
return { syncedCount: recentlyUpdatedMembers.length }
|
||||
}
|
||||
)
|
||||
|
||||
const syncSlackUserToBotpressUser = async (member: Member, botpressClient: bp.Client): Promise<User> => {
|
||||
try {
|
||||
const { user } = await createOrUpdateUser({
|
||||
client: botpressClient,
|
||||
name: member.name,
|
||||
pictureUrl: member.profile?.image_512,
|
||||
tags: {
|
||||
id: member.id,
|
||||
avatar_hash: member.profile?.avatar_hash,
|
||||
display_name: member.profile?.display_name,
|
||||
display_name_normalized: member.profile?.display_name_normalized,
|
||||
email: member.profile?.email,
|
||||
image_24: member.profile?.image_24,
|
||||
image_48: member.profile?.image_48,
|
||||
image_192: member.profile?.image_192,
|
||||
image_512: member.profile?.image_512,
|
||||
image_1024: member.profile?.image_1024,
|
||||
is_admin: member.is_admin?.toString(),
|
||||
is_bot: member.is_bot?.toString(),
|
||||
phone: member.profile?.phone,
|
||||
real_name: member.profile?.real_name,
|
||||
real_name_normalized: member.profile?.real_name_normalized,
|
||||
status_emoji: member.profile?.status_emoji,
|
||||
status_text: member.profile?.status_text,
|
||||
team: member.team_id,
|
||||
title: member.profile?.title,
|
||||
tz: member.tz,
|
||||
},
|
||||
discriminateByTags: ['id'],
|
||||
})
|
||||
|
||||
return user
|
||||
} catch (err) {
|
||||
if (isApiError(err) && err.type === 'RateLimited') {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10 * 1000))
|
||||
return syncSlackUserToBotpressUser(member, botpressClient)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
type SyncState = { usersLastSyncTs?: number }
|
||||
|
||||
const _getSyncState = async (client: bp.Client, ctx: bp.Context): Promise<SyncState> => {
|
||||
const {
|
||||
state: { payload },
|
||||
} = await client.getState({ type: 'integration', name: 'sync', id: ctx.integrationId }).catch(() => ({
|
||||
state: { payload: {} as any },
|
||||
}))
|
||||
|
||||
return payload as SyncState
|
||||
}
|
||||
|
||||
const _saveSyncState = async (client: bp.Client, ctx: bp.Context, state: SyncState) => {
|
||||
await client.setState({ type: 'integration', name: 'sync', id: ctx.integrationId, payload: state })
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { wrapActionAndInjectSlackClient } from './action-wrapper'
|
||||
import { retrieveChannelAndMessageTs } from './utils/message-utils'
|
||||
|
||||
const TYPING_INDICATOR_EMOJI = 'eyes'
|
||||
|
||||
export const startTypingIndicator = wrapActionAndInjectSlackClient(
|
||||
{ actionName: 'startTypingIndicator', errorMessage: 'Failed to start typing indicator' },
|
||||
async ({ ctx, client, slackClient, logger }, { conversationId, messageId }) => {
|
||||
const { channel, ts } = await retrieveChannelAndMessageTs({
|
||||
client,
|
||||
messageId,
|
||||
})
|
||||
|
||||
if (ctx.configuration.typingIndicatorEmoji) {
|
||||
logger
|
||||
.forBot()
|
||||
.debug(
|
||||
`Adding reaction "${TYPING_INDICATOR_EMOJI}" to message ${messageId} in conversation ${conversationId} (typing indicator)`
|
||||
)
|
||||
await slackClient.addReactionToMessage({
|
||||
channelId: channel,
|
||||
messageTs: ts,
|
||||
reactionName: TYPING_INDICATOR_EMOJI,
|
||||
})
|
||||
}
|
||||
|
||||
logger.forBot().debug(`Marking message ${messageId} as seen in conversation ${conversationId} (typing indicator)`)
|
||||
|
||||
await slackClient.markMessageAsSeen({
|
||||
channelId: channel,
|
||||
messageTs: ts,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
export const stopTypingIndicator = wrapActionAndInjectSlackClient(
|
||||
{ actionName: 'stopTypingIndicator', errorMessage: 'Failed to stop typing indicator' },
|
||||
async ({ client, slackClient, logger, ctx }, { messageId, conversationId }) => {
|
||||
const { channel, ts } = await retrieveChannelAndMessageTs({
|
||||
client,
|
||||
messageId,
|
||||
})
|
||||
|
||||
if (ctx.configuration.typingIndicatorEmoji) {
|
||||
logger
|
||||
.forBot()
|
||||
.debug(
|
||||
`Removing reaction "${TYPING_INDICATOR_EMOJI}" from message ${messageId} in conversation ${conversationId} (typing indicator)`
|
||||
)
|
||||
await slackClient.removeReactionFromMessage({
|
||||
channelId: channel,
|
||||
messageTs: ts,
|
||||
reactionName: TYPING_INDICATOR_EMOJI,
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
import { wrapActionAndInjectSlackClient } from './action-wrapper'
|
||||
|
||||
export const updateChannelTopic = wrapActionAndInjectSlackClient(
|
||||
{ actionName: 'updateChannelTopic', errorMessage: 'Failed to update channel topic' },
|
||||
async ({ slackClient }, { channelId, topic }) => {
|
||||
await slackClient.setConversationTopic({ channelId, topic })
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as sdk from '@botpress/client'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const retrieveChannelAndMessageTs = async ({ client, messageId }: { client: bp.Client; messageId: string }) => {
|
||||
const { message } = await client.getMessage({ id: messageId })
|
||||
const { conversation } = await client.getConversation({ id: message.conversationId })
|
||||
|
||||
const channel = conversation.tags.id
|
||||
const ts = message.tags.ts
|
||||
if (!channel) {
|
||||
throw new sdk.RuntimeError('Channel ID is missing in conversation tags')
|
||||
}
|
||||
if (!ts) {
|
||||
throw new sdk.RuntimeError('Timestamp is missing in message tags')
|
||||
}
|
||||
|
||||
return { channel, ts }
|
||||
}
|
||||
Reference in New Issue
Block a user