Skip to content

Commit

Permalink
feat(slack): slack v1 (#331)
Browse files Browse the repository at this point in the history
* start

* text receive send

* token -> botToken

* auth

* fix

* readme

* better text

* image

* readme

* choices

* fix

* dropdown

* better dropdown

* carousel

* refact

* refact

* refact

* audio file location video

* fix

* fix

* add to server

* fix flaky test

* pr comments
  • Loading branch information
samuelmasse committed Jan 31, 2022
1 parent 6717e0a commit bf3c5f5
Show file tree
Hide file tree
Showing 27 changed files with 768 additions and 34 deletions.
2 changes: 2 additions & 0 deletions packages/channels/example/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Router } from 'express'
import Joi from 'joi'
import { Channel } from '../src/base/channel'
import { MessengerChannel } from '../src/messenger/channel'
import { SlackChannel } from '../src/slack/channel'
import { TelegramChannel } from '../src/telegram/channel'
import { TwilioChannel } from '../src/twilio/channel'
import payloads from './payloads.json'
Expand All @@ -12,6 +13,7 @@ export class App {

async setup() {
await this.setupChannel('messenger', new MessengerChannel())
await this.setupChannel('slack', new SlackChannel())
await this.setupChannel('twilio', new TwilioChannel())
await this.setupChannel('telegram', new TelegramChannel())
}
Expand Down
2 changes: 2 additions & 0 deletions packages/channels/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@
"@types/uuid": "^8.3.4"
},
"dependencies": {
"@slack/bolt": "^3.9.0",
"axios": "^0.24.0",
"cli-color": "^2.0.1",
"express": "^4.17.2",
"joi": "^17.5.0",
"lodash": "^4.17.21",
"lru-cache": "^6.0.0",
"ms": "^2.1.3",
"raw-body": "^2.4.2",
"telegraf": "^4.6.0",
"twilio": "^3.73.1",
"uuid": "^8.3.2",
Expand Down
1 change: 1 addition & 0 deletions packages/channels/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './base/channel'
export * from './base/endpoint'
export * from './messenger/channel'
export * from './slack/channel'
export * from './twilio/channel'
export * from './telegram/channel'
48 changes: 24 additions & 24 deletions packages/channels/src/messenger/README.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,29 @@
### Sending

| Channels | Twilio |
| -------- | :----: |
| Text | |
| Image | |
| Choice | |
| Dropdown | |
| Card | |
| Carousel | |
| File | |
| Audio | |
| Video | |
| Location | |
| Channels | Messenger |
| -------- | :-------: |
| Text | |
| Image | |
| Choice | |
| Dropdown | |
| Card | |
| Carousel | |
| File | |
| Audio | |
| Video | |
| Location | |

### Receiving

| Channels | Twilio |
| ------------- | :----: |
| Text | |
| Quick Reply | |
| Postback | |
| Say Something | |
| Voice | |
| Image | |
| File | |
| Audio | |
| Video | |
| Location | |
| Channels | Messenger |
| ------------- | :-------: |
| Text | |
| Quick Reply | |
| Postback | |
| Say Something | |
| Voice | |
| Image | |
| File | |
| Audio | |
| Video | |
| Location | |
29 changes: 29 additions & 0 deletions packages/channels/src/slack/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
### Sending

| Channels | Slack |
| -------- | :---: |
| Text ||
| Image ||
| Choice ||
| Dropdown ||
| Card ||
| Carousel ||
| File ||
| Audio ||
| Video ||
| Location ||

### Receiving

| Channels | Slack |
| ------------- | :---: |
| Text ||
| Quick Reply ||
| Postback ||
| Say Something ||
| Voice ||
| Image ||
| File ||
| Audio ||
| Video ||
| Location ||
173 changes: 173 additions & 0 deletions packages/channels/src/slack/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import {
AckFn,
ButtonAction,
ReceiverEvent,
RespondFn,
SlackAction,
SlackActionMiddlewareArgs,
SlackEventMiddlewareArgs,
StaticSelectAction
} from '@slack/bolt'
import crypto from 'crypto'
import { NextFunction, Response } from 'express'
import rawBody from 'raw-body'
import tsscmp from 'tsscmp'
import { URLSearchParams } from 'url'
import { Endpoint } from '..'
import { ChannelApi, ChannelApiManager, ChannelApiRequest } from '../base/api'
import { POSTBACK_PREFIX, SAY_PREFIX } from '../messenger/renderers/carousel'
import { QUICK_REPLY_PREFIX } from './renderers/choices'
import { SlackService } from './service'

export class SlackApi extends ChannelApi<SlackService> {
async setup(router: ChannelApiManager) {
router.post('/slack', this.verifyRequestSignature.bind(this))
router.post('/slack', this.handleRequest.bind(this))

this.service.on('start', this.handleStart.bind(this))
}

private async verifyRequestSignature(req: ChannelApiRequest, res: Response, next: NextFunction) {
// The verification code is mostly copy pasted from the ExpressReceiver code
// see : https://github.com/slackapi/bolt-js/blob/main/src/receivers/ExpressReceiver.ts

const signature = req.headers['x-slack-signature'] as string | undefined
const requestTimestamp = req.headers['x-slack-request-timestamp'] as string | undefined
const contentType = req.headers['content-type'] as string | undefined

if (!signature || !requestTimestamp) {
return res.sendStatus(401)
}

const ts = Number(requestTimestamp)
if (isNaN(ts)) {
return res.sendStatus(401)
}

// Divide current date to match Slack ts format
// Subtract 5 minutes from current time
const fiveMinutesAgo = Math.floor(Date.now() / 1000) - 60 * 5

if (ts < fiveMinutesAgo) {
// timestamp is too old
return res.sendStatus(403)
}

const { config } = this.service.get(req.scope)
const stringBody = (await rawBody(req)).toString()

const hmac = crypto.createHmac('sha256', config.signingSecret)
const [version, hash] = signature.split('=')
hmac.update(`${version}:${ts}:${stringBody}`)

if (!tsscmp(hash, hmac.digest('hex'))) {
return res.sendStatus(403)
}

// interactive api works with url encoded and events api with json...
if (contentType === 'application/x-www-form-urlencoded') {
const parsedBody = new URLSearchParams(stringBody)
// when we click a button, the payload is actually there in json string
req.body = JSON.parse(parsedBody.get('payload')!)
} else {
req.body = JSON.parse(stringBody)
}

next()
}

private async handleRequest(req: ChannelApiRequest, res: Response) {
if (req.body?.ssl_check) {
return res.send()
} else if (req.body?.type === 'url_verification') {
return res.json({ challenge: req.body.challenge })
} else {
const event: ReceiverEvent = {
body: req.body,
ack: async (response) => {
if (!response) {
res.send('')
} else if (typeof response === 'string') {
res.send(response)
} else {
res.json(response)
}
}
}

const { app } = this.service.get(req.scope)
await app.processEvent(event)
}
}

private async handleStart({ scope }: { scope: string }) {
const { app } = this.service.get(scope)
app.message(async (e) => this.handleMessage(scope, e))
app.action({}, async (e) => this.handleAction(scope, e))
}

private async handleMessage(scope: string, { message }: SlackEventMiddlewareArgs<'message'>) {
if ('bot_id' in message) {
return
}

if ('user' in message) {
await this.service.receive(
scope,
{ identity: '*', sender: message.user, thread: message.channel },
{ type: 'text', text: message.text }
)
}
}

private async handleAction(scope: string, e: SlackActionMiddlewareArgs<SlackAction>) {
const { body, action, respond, ack } = e
const endpoint = { identity: '*', sender: body.user.id, thread: body.channel?.id || '*' }

if (action.type === 'button' && 'text' in action) {
await this.handleButtonAction(scope, { endpoint, action, respond })
} else if (action.type === 'static_select') {
await this.handleSelectAction(scope, { endpoint, action, respond })
}

await ack()
}

private async handleButtonAction(scope: string, e: SlackActionHandler<ButtonAction>) {
const { respond, endpoint, action } = e

if (action.action_id.startsWith(QUICK_REPLY_PREFIX)) {
await respond({ text: `*${action.text.text}*` })

await this.service.receive(scope, endpoint, {
type: 'quick_reply',
text: action.text.text,
payload: action.value
})
} else {
if (action.action_id.startsWith(SAY_PREFIX)) {
await this.service.receive(scope, endpoint, { type: 'say_something', text: action.value })
} else if (action.action_id.startsWith(POSTBACK_PREFIX)) {
await this.service.receive(scope, endpoint, { type: 'postback', payload: action.value })
}
}
}

private async handleSelectAction(scope: string, e: SlackActionHandler<StaticSelectAction>) {
const { respond, action, endpoint } = e

await respond(`*${action.selected_option.text.text}*`)

await this.service.receive(scope, endpoint, {
type: 'quick_reply',
text: action.selected_option.text.text,
payload: action.selected_option.value
})
}
}

interface SlackActionHandler<T> {
endpoint: Endpoint
action: T
respond: RespondFn
}
23 changes: 23 additions & 0 deletions packages/channels/src/slack/channel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ChannelTemplate } from '../base/channel'
import { SlackApi } from './api'
import { SlackConfig, SlackConfigSchema } from './config'
import { SlackService } from './service'
import { SlackStream } from './stream'

export class SlackChannel extends ChannelTemplate<SlackConfig, SlackService, SlackApi, SlackStream> {
get meta() {
return {
id: 'a3551758-a03f-4a68-97f2-4536a8805b52',
name: 'slack',
version: '1.0.0',
schema: SlackConfigSchema,
initiable: false,
lazy: true
}
}

constructor() {
const service = new SlackService()
super(service, new SlackApi(service), new SlackStream(service))
}
}
12 changes: 12 additions & 0 deletions packages/channels/src/slack/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import Joi from 'joi'
import { ChannelConfig } from '../base/config'

export interface SlackConfig extends ChannelConfig {
botToken: string
signingSecret: string
}

export const SlackConfigSchema = {
botToken: Joi.string().required(),
signingSecret: Joi.string().required()
}
8 changes: 8 additions & 0 deletions packages/channels/src/slack/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Block, KnownBlock } from '@slack/bolt'
import { ChatPostMessageArguments } from '@slack/web-api'
import { ChannelContext } from '../base/context'
import { SlackState } from './service'

export type SlackContext = ChannelContext<SlackState> & {
message: ChatPostMessageArguments & { blocks: (KnownBlock | Block)[] }
}
14 changes: 14 additions & 0 deletions packages/channels/src/slack/renderers/audio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { AudioRenderer } from '../../base/renderers/audio'
import { AudioContent } from '../../content/types'
import { SlackContext } from '../context'

export class SlackAudioRenderer extends AudioRenderer {
renderAudio(context: SlackContext, payload: AudioContent) {
context.message.blocks.push({
type: 'section',
text: { type: 'mrkdwn', text: `<${payload.audio}|${payload.title || payload.audio}>` }
})

context.message.text = payload.title || payload.audio
}
}

0 comments on commit bf3c5f5

Please sign in to comment.