Skip to content

Commit

Permalink
fix(messenger): fixes webhooks
Browse files Browse the repository at this point in the history
  • Loading branch information
slvnperron committed Mar 13, 2019
1 parent 97daffd commit c7d8349
Show file tree
Hide file tree
Showing 13 changed files with 216 additions and 72 deletions.
6 changes: 4 additions & 2 deletions modules/channel-messenger/README.md
Expand Up @@ -45,7 +45,8 @@ Head over to `data/bots/<your_bot>/config/channel-messenger.json` and edit your
```json
{
"$schema": "../../../assets/modules/channel-messenger/config.schema.json",
"verifyToken": "<your_token>",
"accessToken": "<your_token>",
"verifyToken": "<generate a random string>",
"greeting": "This is a greeting message!!! Hello Human!",
"getStarted": "You got started!",
"persistentMenu": [
Expand Down Expand Up @@ -76,7 +77,8 @@ The config file for channel-messenger can be found at `data/bots/<your_bot>/conf

| Property | Description |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| verifyToken | The Facebook Page Access Token |
| accessToken | The Facebook Page Access Token |
| verifyToken | A random hard-to-guess string of your choosing |
| greeting | The optional greeting message people will see on the welcome screen. Greeting will not appear if left blank. |
| getStarted | The optional message of the welcome screen "Get Started" button. Get Started wont appear if this is left blank. |
| persistentMenu | The optional raw persistent menu object. The menu won't appear if this is left blank. Please refer to Facebook's [Persistent Menu Documentation](https://developers.facebook.com/docs/messenger-platform/send-messages/persistent-menu/) to know more about menu options. |
Expand Down
17 changes: 15 additions & 2 deletions modules/channel-messenger/src/backend/index.ts
Expand Up @@ -3,18 +3,31 @@ import * as sdk from 'botpress/sdk'

import { MessengerService } from './messenger'

let service: MessengerService

const onServerStarted = async (bp: typeof sdk) => {
const messengerService = new MessengerService(bp)
messengerService.initialize()
service = new MessengerService(bp)
await service.initialize()
}

const onServerReady = (bp: typeof sdk) => {}

const onBotMount = async (bp: typeof sdk, botId: string) => {
await service.mountBot(botId)
}

const onBotUnmount = async (bp: typeof sdk, botId: string) => {
await service.unmountBot(botId)
}

const entryPoint: sdk.ModuleEntryPoint = {
onServerReady,
onServerStarted,
onBotMount: onBotMount,
onBotUnmount: onBotUnmount,
definition: {
name: 'channel-messenger',
noInterface: true,
fullName: 'Messenger Channel'
}
}
Expand Down
141 changes: 101 additions & 40 deletions modules/channel-messenger/src/backend/messenger.ts
Expand Up @@ -10,16 +10,42 @@ import { Config } from '../config'
const outgoingTypes = ['text', 'typing', 'login_prompt', 'carousel']
type MessengerAction = 'typing_on' | 'typing_off' | 'mark_seen'

export class MessengerService {
private readonly http = axios.create({ baseURL: 'https://graph.facebook.com/v2.6/me' })
type MountedBot = { pageId: string; botId: string; client: MessengerClient }

private messengerClients: { [key: string]: MessengerClient } = {}
private router: Router
export class MessengerService {
private readonly http = axios.create({ baseURL: 'https://graph.facebook.com/v3.2/me' })
private mountedBots: MountedBot[] = []
private router: Router & sdk.http.RouterExtension
private appSecret: string

constructor(private bp: typeof sdk) {}

initialize() {
this.router = this.bp.http.createRouterForBot('channel-messenger', { checkAuthentication: false })
async initialize() {
const config = (await this.bp.config.getModuleConfig('channel-messenger')) as Config

if (!config.verifyToken || config.verifyToken.length < 1) {
throw new Error('You need to set a non-empty value for "verifyToken" in the *global* messenger config')
}

if (!config.appSecret || config.appSecret.length < 1) {
throw new Error(`You need to provide your app's App Secret in the *global* messenger config`)
}

this.appSecret = config.appSecret

this.router = this.bp.http.createRouterForBot('channel-messenger', {
checkAuthentication: false,
enableJsonBodyParser: false // we use our custom json body parser instead, see below
})

this.router.getPublicPath().then(publicPath => {
if (publicPath.indexOf('https://') !== 0) {
this.bp.logger.warn('Messenger requires HTTPS to be setup to work properly. See EXTERNAL_URL botpress config.')
}

this.bp.logger.info(`Messenger Webhook URL is ${publicPath.replace('BOT_ID', '___')}/webhook`)
})

this.router.use(
bodyParser.json({
verify: this._verifySignature.bind(this)
Expand All @@ -38,66 +64,106 @@ export class MessengerService {
})
}

getMessengerClient(botId: string): MessengerClient {
if (this.messengerClients[botId]) {
return this.messengerClients[botId]
async mountBot(botId: string) {
const config = (await this.bp.config.getModuleConfigForBot('channel-messenger', botId)) as Config
if (config.enabled) {
if (!config.accessToken) {
return this.bp.logger
.forBot(botId)
.error('You need to configure an Access Token to enable it. Messenger Channel is disabled for this bot.')
}

const { data } = await this.http.get('/', { params: { access_token: config.accessToken } })

if (!data && !data.id) {
return this.bp.logger
.forBot(botId)
.error(
'Could not register bot, are you sure your Access Token is valid? Messenger Channel is disabled for this bot.'
)
}

const pageId = data.id
const client = new MessengerClient(botId, this.bp, this.http)
this.mountedBots.push({ botId: botId, client, pageId })

await client.setupGreeting()
await client.setupGetStarted()
await client.setupPersistentMenu()
}
}

async unmountBot(botId: string) {
this.mountedBots = _.remove(this.mountedBots, x => x.botId === botId)
}

getMessengerClientByBotId(botId: string): MessengerClient {
const entry = _.find(this.mountedBots, x => x.botId === botId)

if (!entry) {
throw new Error(`Can't find a MessengerClient for bot "${botId}"`)
}

this.messengerClients[botId] = new MessengerClient(botId, this.bp, this.http)
return this.messengerClients[botId]
return entry.client
}

// See: https://developers.facebook.com/docs/messenger-platform/webhook#security
private async _verifySignature(req, res, buffer) {
const client = this.getMessengerClient(req.params.botId)
const config = await client.getConfig()
const signatureError = new Error("Couldn't validate the request signature.")

if (!/^\/webhook/i.test(req.path)) {
return
}

console.log(req.path, req.headers, req.body)

const signature = req.headers['x-hub-signature']
if (!signature) {
if (!signature || !this.appSecret) {
throw signatureError
} else {
const [, hash] = signature.split('=')

const expectedHash = crypto
.createHmac('sha1', config.verifyToken)
.createHmac('sha1', this.appSecret)
.update(buffer)
.digest('hex')

if (hash != expectedHash) {
if (hash !== expectedHash) {
throw signatureError
}
}
}

private async _handleIncomingMessage(req, res) {
const body = req.body
const botId = req.params.botId
const client = this.getMessengerClient(botId)

if (body.object !== 'page') {
res.sendStatus(404)
return
//
} else {
res.status(200).send('EVENT_RECEIVED')
}

for (const entry of body.entry) {
// Will only ever contain one message, so we get index 0
const webhookEvent = entry.messaging[0]
const senderId = webhookEvent.sender.id
const pageId = entry.id
const messages = entry.messaging

const bot = _.find<MountedBot>(this.mountedBots, { pageId })
if (!bot) {
continue
}

await client.sendAction(senderId, 'mark_seen')
for (const webhookEvent of messages) {
const senderId = webhookEvent.sender.id

if (webhookEvent.message) {
await this._sendEvent(botId, senderId, webhookEvent.message, { type: 'message' })
} else if (webhookEvent.postback) {
await this._sendEvent(botId, senderId, { text: webhookEvent.postback.payload }, { type: 'callback' })
await bot.client.sendAction(senderId, 'mark_seen')

if (webhookEvent.message) {
await this._sendEvent(req.BOT_ID, senderId, webhookEvent.message, { type: 'message' })
} else if (webhookEvent.postback) {
await this._sendEvent(req.BOT_ID, senderId, { text: webhookEvent.postback.payload }, { type: 'callback' })
}
}
}

res.status(200).send('EVENT_RECEIVED')
}

private async _sendEvent(botId: string, senderId: string, message, args: { type: string }) {
Expand All @@ -115,24 +181,19 @@ export class MessengerService {
}

private async _setupWebhook(req, res) {
console.log('setup', req)
const mode = req.query['hub.mode']
const token = req.query['hub.verify_token']
const challenge = req.query['hub.challenge']
const botId = req.params.botId

const client = this.getMessengerClient(botId)
const config = await client.getConfig()
const config = (await this.bp.config.getModuleConfig('channel-messenger')) as Config

if (mode && token && mode === 'subscribe' && token === config.verifyToken) {
this.bp.logger.forBot(botId).debug('Webhook Verified.')
this.bp.logger.debug('Webhook Verified')
res.status(200).send(challenge)
} else {
res.sendStatus(403)
}

await client.setupGreeting()
await client.setupGetStarted()
await client.setupPersistentMenu()
}

private async _handleOutgoingEvent(event: sdk.IO.Event, next: sdk.IO.MiddlewareNextCallback) {
Expand All @@ -141,7 +202,7 @@ export class MessengerService {
}

const messageType = event.type === 'default' ? 'text' : event.type
const messenger = this.getMessengerClient(event.botId)
const messenger = this.getMessengerClientByBotId(event.botId)

if (!_.includes(outgoingTypes, messageType)) {
return next(new Error('Unsupported event type: ' + event.type))
Expand Down Expand Up @@ -248,6 +309,6 @@ export class MessengerClient {

private async _callEndpoint(endpoint: string, body) {
const config = await this.getConfig()
await this.http.post(endpoint, body, { params: { access_token: config.verifyToken } })
await this.http.post(endpoint, body, { params: { access_token: config.accessToken } })
}
}
51 changes: 50 additions & 1 deletion modules/channel-messenger/src/config.ts
@@ -1,19 +1,68 @@
export interface Config {
/**
* This this in the LOCAL config (unique to every bot/page)
* Whether or not the messenger module is enabled for this bot
* @default false
*/
enabled: boolean
/**
* This this in the LOCAL config (unique to every bot/page)
* The Facebook Page Access Token
*/
accessToken: string
/**
* This this in the GLOBAL config (same for all bots)
* Your app's "App Secret"
* Find this secret in your developers.facebook.com -> your app -> Settings -> Basic -> App Secret -> Show
*/
appSecret: string
/**
* Set this in the GLOBAL config (same for all the bots)
* The verify token, should be a random string unique to your server. This is a random (hard to guess) string of your choosing.
* @see https://developers.facebook.com/docs/messenger-platform/getting-started/webhook-setup/#verify_webhook
*/
verifyToken: string
/**
* The greeting message people will see on the welcome screen
* @see https://developers.facebook.com/docs/messenger-platform/reference/messenger-profile-api/greeting
*/
greeting?: string
/**
* The message of the welcome screen "Get Started" button
* @see https://developers.facebook.com/docs/messenger-platform/reference/messenger-profile-api/get-started-button
*/
getStarted?: string
/**
* The raw persistent menu object
* @see https://developers.facebook.com/docs/messenger-platform/send-messages/persistent-menu/
* @example
* {
* "persistent_menu":[
* {
* "locale":"default",
* "composer_input_disabled": true,
* "call_to_actions":[
* {
* "title":"My Account",
* "type":"nested",
* "call_to_actions":[
* {
* "title":"Pay Bill",
* "type":"postback",
* "payload":"PAYBILL_PAYLOAD"
* },
* {
* "type":"web_url",
* "title":"Latest News",
* "url":"https://www.messenger.com/",
* "webview_height_ratio":"full"
* }
* ]
* }
* ]
* }
* ]
* }
*/
persistentMenu?
persistentMenu?: any // TODO Type me
}
2 changes: 1 addition & 1 deletion src/bp/core/api.ts
Expand Up @@ -35,7 +35,7 @@ const http = (httpServer: HTTPServer): typeof sdk.http => {
deleteShortLink(name: string): void {
httpServer.deleteShortLink(name)
},
createRouterForBot(routerName: string, options?: sdk.RouterOptions): any {
createRouterForBot(routerName: string, options?: sdk.RouterOptions): any & sdk.http.RouterExtension {
const defaultRouterOptions = { checkAuthentication: true, enableJsonBodyParser: true }
return httpServer.createRouterForBot(routerName, options || defaultRouterOptions)
},
Expand Down
6 changes: 0 additions & 6 deletions src/bp/core/app.inversify.ts
@@ -1,6 +1,5 @@
import { Logger } from 'botpress/sdk'
import { Container } from 'inversify'
import { AppLifecycle } from 'lifecycle'

import { BotpressAPIProvider } from './api'
import { Botpress } from './botpress'
Expand Down Expand Up @@ -50,11 +49,6 @@ container.bind<LoggerProvider>(TYPES.LoggerProvider).toProvider<Logger>(context
}
})

container
.bind<AppLifecycle>(TYPES.AppLifecycle)
.to(AppLifecycle)
.inSingletonScope()

container
.bind<LoggerDbPersister>(TYPES.LoggerDbPersister)
.to(LoggerDbPersister)
Expand Down

0 comments on commit c7d8349

Please sign in to comment.