Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(integration/slack): added oauth to slack integration #12846

Merged
merged 6 commits into from
Nov 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/actions/deploy-integrations/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ input:
linear_webhook_signing_secret:
description: 'Linear webhook signing secret'
required: true
slack_client_id:
description: 'Slack client id'
required: true
slack_client_secret:
description: 'Slack client secret'
required: true

runs:
using: 'composite'
Expand Down Expand Up @@ -97,7 +103,7 @@ runs:
echo "### Deploying integration @botpresshub/notion ###"
pnpm -r --stream -F @botpresshub/notion -c exec -- 'bp deploy -v -y --noBuild'
echo "### Deploying integration @botpresshub/slack ###"
pnpm -r --stream -F @botpresshub/slack -c exec -- 'dsn=$(cat .sentryclirc | grep dsn | sed "s/dsn=//"); bp deploy -v -y --noBuild --secrets SENTRY_DSN="$dsn" --secrets SENTRY_ENVIRONMENT="$SENTRY_ENVIRONMENT" --secrets SENTRY_RELEASE="$SENTRY_RELEASE"'
pnpm -r --stream -F @botpresshub/slack -c exec -- 'dsn=$(cat .sentryclirc | grep dsn | sed "s/dsn=//"); bp deploy -v -y --noBuild --secrets SENTRY_DSN="$dsn" --secrets SENTRY_ENVIRONMENT="$SENTRY_ENVIRONMENT" --secrets SENTRY_RELEASE="$SENTRY_RELEASE" --secrets CLIENT_ID="${{ inputs.slack_client_id }}" --secrets CLIENT_SECRET="${{ inputs.slack_client_secret }}"'
echo "### Deploying integration @botpresshub/sunco ###"
pnpm -r --stream -F @botpresshub/sunco -c exec -- 'dsn=$(cat .sentryclirc | grep dsn | sed "s/dsn=//"); bp deploy -v -y --noBuild --secrets SENTRY_DSN="$dsn" --secrets SENTRY_ENVIRONMENT="$SENTRY_ENVIRONMENT" --secrets SENTRY_RELEASE="$SENTRY_RELEASE"'
echo "### Deploying integration @botpresshub/teams ###"
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/deploy-integrations-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,5 @@ jobs:
linear_client_id: ${{ secrets.PRODUCTION_LINEAR_CLIENT_ID }}
linear_client_secret: ${{ secrets.PRODUCTION_LINEAR_CLIENT_SECRET }}
linear_webhook_signing_secret: ${{ secrets.PRODUCTION_LINEAR_WEBHOOK_SIGNING_SECRET }}
slack_client_id: ${{ secrets.PRODUCTION_SLACK_CLIENT_ID }}
slack_client_secret: ${{ secrets.PRODUCTION_SLACK_CLIENT_SECRET }}
2 changes: 2 additions & 0 deletions .github/workflows/deploy-integrations-staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,5 @@ jobs:
linear_client_id: ${{ secrets.STAGING_LINEAR_CLIENT_ID }}
linear_client_secret: ${{ secrets.STAGING_LINEAR_CLIENT_SECRET }}
linear_webhook_signing_secret: ${{ secrets.STAGING_LINEAR_WEBHOOK_SIGNING_SECRET }}
slack_client_id: ${{ secrets.STAGING_SLACK_CLIENT_ID }}
slack_client_secret: ${{ secrets.STAGING_SLACK_CLIENT_SECRET }}
10 changes: 9 additions & 1 deletion integrations/slack/integration.definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,15 @@ export default new IntegrationDefinition({
channels,
actions,
events,
secrets: sentryHelpers.COMMON_SECRET_NAMES,
secrets: {
CLIENT_ID: {
description: 'The client ID of your Slack OAuth app.',
},
CLIENT_SECRET: {
description: 'The client secret of your Slack OAuth app.',
},
...sentryHelpers.COMMON_SECRET_NAMES,
},
user,
identifier: {
extractScript: 'extract.vrl',
Expand Down
1 change: 1 addition & 0 deletions integrations/slack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@slack/web-api": "^6.8.0",
"axios": "^1.3.4",
"fuse.js": "^6.6.2",
"query-string": "^6.14.1",
"verror": "^1.10.1",
"zod": "^3.20.6"
},
Expand Down
5 changes: 3 additions & 2 deletions integrations/slack/src/actions/add-reaction.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { WebClient } from '@slack/web-api'
import { Implementation } from '../misc/types'
import { getTag } from '../misc/utils'
import { getAccessToken, getTag } from '../misc/utils'

export const addReaction: Implementation['actions']['addReaction'] = async ({ ctx, client, input }) => {
const slackClient = new WebClient(ctx.configuration.botToken)
const accessToken = await getAccessToken(client, ctx)
const slackClient = new WebClient(accessToken)

if (input.messageId) {
const { message } = await client.getMessage({ id: input.messageId })
Expand Down
6 changes: 4 additions & 2 deletions integrations/slack/src/actions/find-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { WebClient } from '@slack/web-api'
import Fuse from 'fuse.js'
import { Target } from '../definitions/actions'
import { Implementation } from '../misc/types'
import { getAccessToken } from '../misc/utils'

const fuse = new Fuse<Target>([], {
shouldSort: true,
Expand All @@ -15,8 +16,9 @@ const fuse = new Fuse<Target>([], {
keys: ['displayName'],
})

export const findTarget: Implementation['actions']['findTarget'] = async ({ ctx, input }) => {
const client = new WebClient(ctx.configuration.botToken)
export const findTarget: Implementation['actions']['findTarget'] = async ({ client: botpressClient, ctx, input }) => {
const accessToken = await getAccessToken(botpressClient, ctx)
const client = new WebClient(accessToken)

const targets: Target[] = []

Expand Down
6 changes: 4 additions & 2 deletions integrations/slack/src/actions/retreive-message.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { WebClient } from '@slack/web-api'
import { getAccessToken } from '../misc/utils'
import { Integration } from '.botpress'

export const retrieveMessage: Integration['actions']['retrieveMessage'] = async ({ ctx, input, logger }) => {
const slackClient = new WebClient(ctx.configuration.botToken)
export const retrieveMessage: Integration['actions']['retrieveMessage'] = async ({ client, ctx, input, logger }) => {
const accessToken = await getAccessToken(client, ctx)
const slackClient = new WebClient(accessToken)

const response = await slackClient.conversations.history({
limit: 1,
Expand Down
57 changes: 34 additions & 23 deletions integrations/slack/src/channels.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import { textSchema } from './definitions/schemas'
import { renderCard } from './misc/renderer'
import { Channels } from './misc/types'
import { getSlackTarget, notEmpty, sendSlackMessage } from './misc/utils'
import { getAccessToken, getSlackTarget, notEmpty, sendSlackMessage } from './misc/utils'

const defaultMessages: Channels['channel']['messages'] = {
text: async ({ payload, ctx, conversation, ack }) => {
text: async ({ client, payload, ctx, conversation, ack }) => {
const parsed = textSchema.parse(payload)
await sendSlackMessage(ctx.configuration.botToken, ack, {
const accessToken = await getAccessToken(client, ctx)
await sendSlackMessage(accessToken, ack, {
...getSlackTarget(conversation),
...parsed,
})
},
image: async ({ payload, ctx, conversation, ack }) => {
await sendSlackMessage(ctx.configuration.botToken, ack, {
image: async ({ client, payload, ctx, conversation, ack }) => {
const accessToken = await getAccessToken(client, ctx)
await sendSlackMessage(accessToken, ack, {
...getSlackTarget(conversation),
blocks: [
{
Expand All @@ -23,8 +25,9 @@ const defaultMessages: Channels['channel']['messages'] = {
],
})
},
markdown: async ({ ctx, conversation, ack, payload }) => {
await sendSlackMessage(ctx.configuration.botToken, ack, {
markdown: async ({ ctx, conversation, ack, client, payload }) => {
const accessToken = await getAccessToken(client, ctx)
await sendSlackMessage(accessToken, ack, {
...getSlackTarget(conversation),
text: payload.markdown,
blocks: [
Expand All @@ -35,8 +38,9 @@ const defaultMessages: Channels['channel']['messages'] = {
],
})
},
audio: async ({ ctx, conversation, ack, payload }) => {
await sendSlackMessage(ctx.configuration.botToken, ack, {
audio: async ({ ctx, conversation, ack, client, payload }) => {
const accessToken = await getAccessToken(client, ctx)
await sendSlackMessage(accessToken, ack, {
...getSlackTarget(conversation),
text: 'audio',
blocks: [
Expand All @@ -47,8 +51,9 @@ const defaultMessages: Channels['channel']['messages'] = {
],
})
},
video: async ({ ctx, conversation, ack, payload }) => {
await sendSlackMessage(ctx.configuration.botToken, ack, {
video: async ({ ctx, conversation, ack, client, payload }) => {
const accessToken = await getAccessToken(client, ctx)
await sendSlackMessage(accessToken, ack, {
...getSlackTarget(conversation),
text: 'video',
blocks: [
Expand All @@ -59,8 +64,9 @@ const defaultMessages: Channels['channel']['messages'] = {
],
})
},
file: async ({ ctx, conversation, ack, payload }) => {
await sendSlackMessage(ctx.configuration.botToken, ack, {
file: async ({ ctx, conversation, ack, client, payload }) => {
const accessToken = await getAccessToken(client, ctx)
await sendSlackMessage(accessToken, ack, {
...getSlackTarget(conversation),
text: 'file',
blocks: [
Expand All @@ -71,9 +77,10 @@ const defaultMessages: Channels['channel']['messages'] = {
],
})
},
location: async ({ ctx, conversation, ack, payload }) => {
location: async ({ ctx, conversation, ack, client, payload }) => {
const googleMapsLink = `https://www.google.com/maps/search/?api=1&query=${payload.latitude},${payload.longitude}`
await sendSlackMessage(ctx.configuration.botToken, ack, {
const accessToken = await getAccessToken(client, ctx)
await sendSlackMessage(accessToken, ack, {
...getSlackTarget(conversation),
text: 'location',
blocks: [
Expand All @@ -84,22 +91,25 @@ const defaultMessages: Channels['channel']['messages'] = {
],
})
},
carousel: async ({ ctx, conversation, ack, payload }) => {
await sendSlackMessage(ctx.configuration.botToken, ack, {
carousel: async ({ ctx, conversation, ack, client, payload }) => {
const accessToken = await getAccessToken(client, ctx)
await sendSlackMessage(accessToken, ack, {
...getSlackTarget(conversation),
text: 'carousel',
blocks: payload.items.flatMap(renderCard).filter(notEmpty),
})
},
card: async ({ ctx, conversation, ack, payload }) => {
await sendSlackMessage(ctx.configuration.botToken, ack, {
card: async ({ ctx, conversation, ack, client, payload }) => {
const accessToken = await getAccessToken(client, ctx)
await sendSlackMessage(accessToken, ack, {
...getSlackTarget(conversation),
text: 'card',
blocks: renderCard(payload),
})
},
dropdown: async ({ ctx, conversation, ack, payload }) => {
await sendSlackMessage(ctx.configuration.botToken, ack, {
dropdown: async ({ ctx, conversation, ack, client, payload }) => {
const accessToken = await getAccessToken(client, ctx)
await sendSlackMessage(accessToken, ack, {
...getSlackTarget(conversation),
text: payload.text,
blocks:
Expand Down Expand Up @@ -131,8 +141,9 @@ const defaultMessages: Channels['channel']['messages'] = {
: undefined,
})
},
choice: async ({ ctx, conversation, ack, payload }) => {
await sendSlackMessage(ctx.configuration.botToken, ack, {
choice: async ({ ctx, conversation, ack, client, payload }) => {
const accessToken = await getAccessToken(client, ctx)
await sendSlackMessage(accessToken, ack, {
...getSlackTarget(conversation),
text: payload.text,
blocks:
Expand Down
6 changes: 6 additions & 0 deletions integrations/slack/src/definitions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ export const states = {
botUserId: z.string().optional(),
}),
},
credentials: {
type: 'integration',
schema: z.object({
accessToken: z.string(),
}),
},
} satisfies IntegrationDefinitionProps['states']

export const user = {
Expand Down
7 changes: 5 additions & 2 deletions integrations/slack/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ import {

import * as botpress from '.botpress'

export const handler: botpress.IntegrationProps['handler'] = async ({ req, ctx, client }) => {
export const handler: botpress.IntegrationProps['handler'] = async ({ req, ctx, client, logger }) => {
if (req.path.startsWith('/oauth')) {
return onOAuth()
return onOAuth(req, client, ctx).catch((err) => {
logger.forBot().error('Error while processing OAuth', err.response?.data || err.message)
throw err
})
}

if (!req.body) {
Expand Down
84 changes: 81 additions & 3 deletions integrations/slack/src/misc/utils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { Conversation } from '@botpress/client'
import type { AckFunction, Request } from '@botpress/sdk'
import type { AckFunction, IntegrationContext, Request } from '@botpress/sdk'
import { ChatPostMessageArguments, WebClient } from '@slack/web-api'
import axios from 'axios'
import queryString from 'query-string'
import VError from 'verror'
import { INTEGRATION_NAME } from '../const'
import { Configuration } from '../setup'
import { Client, IntegrationCtx } from './types'
import * as bp from '.botpress'

type InteractiveBody = {
response_url: string
Expand Down Expand Up @@ -48,8 +50,74 @@ function getTags(message: SlackMessage) {
return tags
}

export function onOAuth() {
return {}
const oauthHeaders = {
'Content-Type': 'application/x-www-form-urlencoded',
} as const

export class SlackOauthClient {
private clientId: string
private clientSecret: string

constructor() {
this.clientId = bp.secrets.CLIENT_ID
this.clientSecret = bp.secrets.CLIENT_SECRET
}

async getAccessToken(code: string) {
const res = await axios.post(
'https://slack.com/api/oauth.v2.access',
{
client_id: this.clientId,
client_secret: this.clientSecret,
redirect_uri: `${process.env.BP_WEBHOOK_URL}/oauth`,
code,
},
{
headers: oauthHeaders,
}
)

const { access_token } = res.data

if (!access_token) {
throw new Error('No access token found')
}

return access_token as string
}
}

export async function onOAuth(req: Request, client: bp.Client, ctx: IntegrationContext) {
const slackOAuthClient = new SlackOauthClient()

const query = queryString.parse(req.query)
const code = query.code

if (typeof code !== 'string') {
throw new Error('Handler received an empty code')
}

const accessToken = await slackOAuthClient.getAccessToken(code)

await client.setState({
type: 'integration',
name: 'credentials',
id: ctx.integrationId,
payload: {
accessToken,
},
})

const slackClient = new WebClient(accessToken)
const { team } = await slackClient.team.info()

const teamId = team?.id

if (!teamId) {
throw new Error('No team ID found')
}

await client.configureIntegration({ identifier: teamId })
}

export const getSlackTarget = (conversation: Conversation) => {
Expand Down Expand Up @@ -155,3 +223,13 @@ export const getConfig = async (client: Client, ctx: IntegrationCtx): Promise<Co

return payload as Configuration
}

export const getAccessToken = async (client: Client, ctx: IntegrationCtx) => {
if (ctx.configuration.botToken) {
return ctx.configuration.botToken
}

const { state } = await client.getState({ type: 'integration', name: 'credentials', id: ctx.integrationId })

return state.payload.accessToken
}