Skip to content

Commit

Permalink
feat: channel twilio (#4263)
Browse files Browse the repository at this point in the history
* deps

* feat: bring back channel-twilio

* fix(channel-twilio): handleOutgoingEvent (#4314)

* fix(channel-twilio): handleOutgoingEvent

* remove debug log

* fix(channel-twillio): mediaUrl on card element (#4320)

this fixes an error thrown when the mediaUrl is not defined in a card/carrusel element

Co-authored-by: Roberto Escalante H <30730628+RobertoE91@users.noreply.github.com>
  • Loading branch information
EFF and RobertoE91 committed Jan 8, 2021
1 parent 9931698 commit 8e26207
Show file tree
Hide file tree
Showing 12 changed files with 820 additions and 0 deletions.
8 changes: 8 additions & 0 deletions modules/channel-twilio/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/bin
/node_modules
/node_production_modules
/dist
/assets/web/
/assets/config.schema.json
botpress.d.ts
global.d.ts
34 changes: 34 additions & 0 deletions modules/channel-twilio/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Channel-Twilio

### Prerequisite

- An HTTPS Endpoint to your bot
- Set the externalUrl field in botpress.config.json
- Create an HTTPS tunnel to your machine using Ngrok. Tutorial
- Using Nginx and Let's Encrypt. Tutorial

- Create a Twilio account and create a phone number

### Steps

#### Get your API credentials

1. Go to you twilio console dashboard
2. Go to the settings tab
3. Scroll down and copy your Account SID and Auth Token from the LIVE credentials section

#### Configure your bot

1. Edit data/bots/YOUR_BOT_ID/config/channel-twilio.json (or create it) and set
- enabled: Set to true
- accountSID: Paste your account SID
- authToken: Paste your auth token
2. Restart Botpress
3. You should see your webhook endpoint in the console on startup

#### Configure webhook

1. Go to the phone numbers section
2. Click on your registered phone number
3. Scroll down to the messaging webhook section
4. Set it to `EXTERNAL_URL/api/v1/bots/YOUR_BOT_ID/mod/channel-twilio/webhook`
27 changes: 27 additions & 0 deletions modules/channel-twilio/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "channel-twilio",
"fullName": "Channel - Twilio",
"version": "1.0.0",
"status": "stable",
"description": "A Botpress connector for Twilio",
"private": true,
"main": "dist/backend/index.js",
"scripts": {
"build": "node ../../build/module-builder/bin/entry build",
"watch": "node ../../build/module-builder/bin/entry watch",
"package": "node ../../build/module-builder/bin/entry package"
},
"author": "Botpress, Inc.",
"license": "AGPL-3.0-only",
"resolutions": {
"fstream": ">=1.0.12",
"lodash": ">=4.17.12"
},
"devDependencies": {
"@types/node": "^10.10.3",
"@types/twilio": "^2.11.0"
},
"dependencies": {
"twilio": "^3.47.0"
}
}
27 changes: 27 additions & 0 deletions modules/channel-twilio/src/backend/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import * as sdk from 'botpress/sdk'

import { Clients } from './typings'

export async function setupRouter(bp: typeof sdk, clients: Clients, route: string): Promise<sdk.http.RouterExtension> {
const router = bp.http.createRouterForBot('channel-twilio', {
checkAuthentication: false
})

router.post(route, async (req, res) => {
const { botId } = req.params
const client = clients[botId]

if (!client) {
return res.status(404).send('Bot not a twilio bot')
}

if (client.auth(req)) {
await client.handleWebhookRequest(req.body)
res.sendStatus(204)
} else {
res.status(401).send('Auth token invalid')
}
})

return router
}
205 changes: 205 additions & 0 deletions modules/channel-twilio/src/backend/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import * as sdk from 'botpress/sdk'
import { Twilio, validateRequest } from 'twilio'
import { MessageInstance } from 'twilio/lib/rest/api/v2010/account/message'

import { Config } from '../config'

import { ChoiceOption, Clients, MessageOption, TwilioRequestBody } from './typings'

const debug = DEBUG('channel-twilio')
const debugIncoming = debug.sub('incoming')
const debugOutgoing = debug.sub('outgoing')

export const MIDDLEWARE_NAME = 'twilio.sendMessage'

export class TwilioClient {
private logger: sdk.Logger
private twilio: Twilio
private webhookUrl: string
private kvs: sdk.KvsService

constructor(
private bp: typeof sdk,
private botId: string,
private config: Config,
private router: sdk.http.RouterExtension,
private route: string
) {
this.logger = bp.logger.forBot(botId)
}

async initialize() {
if (!this.config.accountSID || !this.config.authToken) {
return this.logger.error(`[${this.botId}] The accountSID and authToken must be configured to use this channel.`)
}

const url = (await this.router.getPublicPath()) + this.route
this.webhookUrl = url.replace('BOT_ID', this.botId)

this.twilio = new Twilio(this.config.accountSID, this.config.authToken)
this.kvs = this.bp.kvs.forBot(this.botId)

this.logger.info(`Twilio webhook listening at ${this.webhookUrl}`)
}

auth(req): boolean {
const signature = req.headers['x-twilio-signature']
return validateRequest(this.config.authToken, signature, this.webhookUrl, req.body)
}

getKvsKey(target: string, threadId: string) {
return `${target}_${threadId}`
}

async handleWebhookRequest(body: TwilioRequestBody) {
debugIncoming('Received message', body)

const threadId = body.To
const target = body.From
const text = body.Body

const index = Number(text)
let payload: any = { type: 'text', text }
if (index) {
payload = (await this.handleIndexReponse(index - 1, target, threadId)) ?? payload
if (payload.type === 'url') {
return
}
}

await this.kvs.delete(this.getKvsKey(target, threadId))

await this.bp.events.sendEvent(
this.bp.IO.Event({
botId: this.botId,
channel: 'twilio',
direction: 'incoming',
type: payload.type,
payload,
threadId,
target
})
)
}

async handleIndexReponse(index: number, target: string, threadId: string): Promise<any> {
const key = this.getKvsKey(target, threadId)
if (!(await this.kvs.exists(key))) {
return
}

const option = await this.kvs.get(key, `[${index}]`)
if (!option) {
return
}

await this.kvs.delete(key)

const { type, label, value } = option
return {
type,
text: type === 'say_something' ? value : label,
payload: value
}
}

async handleOutgoingEvent(event: sdk.IO.Event, next: sdk.IO.MiddlewareNextCallback) {
const payload = event.payload

if (payload.quick_replies) {
await this.sendChoices(event, payload.quick_replies)
} else if (payload.type === 'text') {
await this.sendMessage(event, {
body: payload.text
})
} else if (payload.type === 'file') {
await this.sendMessage(event, {
body: payload.title,
mediaUrl: payload.url
})
} else if (payload.type === 'carousel') {
await this.sendCarousel(event, payload)
}
next(undefined, false)
}

async sendChoices(event: sdk.IO.Event, choices: ChoiceOption[]) {
const options: MessageOption[] = choices.map(x => ({
label: x.title,
value: x.payload,
type: 'quick_reply'
}))
await this.sendOptions(event, event.payload.text, {}, options)
}

async sendCarousel(event: sdk.IO.Event, payload: any) {
for (const { subtitle, title, picture, buttons } of payload.elements) {
const body = `${title}\n\n${subtitle ? subtitle : ''}`

const options: MessageOption[] = []
for (const button of buttons || []) {
const title = button.title as string

if (button.type === 'open_url') {
options.push({ label: `${title} : ${button.url}`, value: undefined, type: 'url' })
} else if (button.type === 'postback') {
options.push({ label: title, value: button.payload, type: 'postback' })
} else if (button.type === 'say_something') {
options.push({ label: title, value: button.text as string, type: 'say_something' })
}
}

const args = { mediaUrl: picture ? picture : undefined }
await this.sendOptions(event, body, args, options)
}
}

async sendOptions(event: sdk.IO.Event, text: string, args: any, options: MessageOption[]) {
let body = text
if (options.length) {
body = `${text}\n\n${options.map(({ label }, idx) => `${idx + 1}. ${label}`).join('\n')}`
}

await this.kvs.set(this.getKvsKey(event.target, event.threadId), options, undefined, '10m')

await this.sendMessage(event, { ...args, body })
}

async sendMessage(event: sdk.IO.Event, args: any) {
const message: MessageInstance = {
...args,
provideFeedback: false,
from: event.threadId,
to: event.target
}

debugOutgoing('Sending message', message)

await this.twilio.messages.create(message)
}
}

export async function setupMiddleware(bp: typeof sdk, clients: Clients) {
bp.events.registerMiddleware({
description:
'Sends out messages that targets platform = Twilio.' +
' This middleware should be placed at the end as it swallows events once sent.',
direction: 'outgoing',
handler: outgoingHandler,
name: MIDDLEWARE_NAME,
order: 100
})

async function outgoingHandler(event: sdk.IO.Event, next: sdk.IO.MiddlewareNextCallback) {
if (event.channel !== 'twilio') {
return next()
}

const client: TwilioClient = clients[event.botId]
if (!client) {
return next()
}

return client.handleOutgoingEvent(event, next)
}
}
61 changes: 61 additions & 0 deletions modules/channel-twilio/src/backend/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import * as sdk from 'botpress/sdk'
import { Config } from 'src/config'

import { setupRouter } from './api'
import { MIDDLEWARE_NAME, setupMiddleware, TwilioClient } from './client'
import { Clients } from './typings'

let router: sdk.http.RouterExtension
const route = '/webhook'
const clients: Clients = {}

const onServerStarted = async (bp: typeof sdk) => {
await setupMiddleware(bp, clients)
}

const onServerReady = async (bp: typeof sdk) => {
router = await setupRouter(bp, clients, route)
}

const onBotMount = async (bp: typeof sdk, botId: string) => {
const config = (await bp.config.getModuleConfigForBot('channel-twilio', botId, true)) as Config

if (config.enabled) {
const client = new TwilioClient(bp, botId, config, router, route)
await client.initialize()

clients[botId] = client
}
}

const onBotUnmount = async (bp: typeof sdk, botId: string) => {
const client = clients[botId]
if (!client) {
return
}

delete clients[botId]
}

const onModuleUnmount = async (bp: typeof sdk) => {
bp.events.removeMiddleware(MIDDLEWARE_NAME)
}

const entryPoint: sdk.ModuleEntryPoint = {
onServerStarted,
onServerReady,
onBotMount,
onBotUnmount,
onModuleUnmount,
definition: {
name: 'channel-twilio',
menuIcon: 'none',
fullName: 'Twilio',
homepage: 'https://botpress.com',
noInterface: true,
plugins: [],
experimental: true
}
}

export default entryPoint
21 changes: 21 additions & 0 deletions modules/channel-twilio/src/backend/typings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { TwilioClient } from './client'

export interface Clients {
[botId: string]: TwilioClient
}

export interface MessageOption {
label: string
value: string
type: 'say_something' | 'postback' | 'quick_reply' | 'url'
}

export interface TwilioRequestBody {
To: string
From: string
Body: string
}
export interface ChoiceOption {
title: string
payload: string
}

0 comments on commit 8e26207

Please sign in to comment.