-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
1 parent
9931698
commit 8e26207
Showing
12 changed files
with
820 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
Oops, something went wrong.