diff --git a/README.md b/README.md index 0022fd51a..8f5d277bd 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ The botpress messaging server provides a standardized messaging api to communica - Twilio - Smooch - Vonage +- Whatsapp ## Getting started diff --git a/docs/channels/v1/readme.md b/docs/channels/v1/readme.md index 861819399..e5e9881b0 100644 --- a/docs/channels/v1/readme.md +++ b/docs/channels/v1/readme.md @@ -11,6 +11,7 @@ - [Telegram](./telegram.md) - [Twilio](./twilio.md) - [Vonage](./vonage.md) +- [Whatsapp](./whatsapp.md) ## Development diff --git a/docs/channels/v1/whatsapp.md b/docs/channels/v1/whatsapp.md new file mode 100644 index 000000000..fcd3abb87 --- /dev/null +++ b/docs/channels/v1/whatsapp.md @@ -0,0 +1,89 @@ +# Whatsapp + +## Requirements + +You will need a Meta app to connect your bot to Whatsapp. + +### Create a Meta App + +To create a Meta app, go to the [Meta for Developers website](https://developers.facebook.com/) and log in with your Facebook account. Select **My Apps** from the top menu, and create a new app. For more details and assistance, visit the [Meta developer documentation](https://developers.facebook.com/docs/development). + +## Channel Configuration + +### API version + +The whatsapp channel is made to interact with version 20.0 or higher of the Whatsapp Cloud API. If it is not the default version so it must be changed in your app's settings. + +1. Go to your Meta App. +2. In the left sidebar, expand the **Settings** menu and select **Advanced**. +3. In the **Upgrade API version** section, select v20.0 or higher as the API version. +4. Click on **Save changes**. + +### Add Whatsapp Product + +Whatsapp is not added by default in your Meta App, so it must be added manually. + +1. In the left sidebar, click on **Dashboard**. +2. In the **Add products** section, click on **Set Up** button on Whatsapp. + +### App ID and Secret + +The `appId` and `appSecret` are used to validate webhook requests. + +1. In the left sidebar, expand the **Settings** menu and select **Basic**. Here you can find the **App ID** and **App secret**. +2. Click on the **Show** button in the **App secret** text box. Copy the **appId** and **appSecret** to your channel configuration. + +### Phone Number ID and Access Token + +The `phoneNumberId` and `accessToken` are used to send messages to the Whatsapp Cloud API. + +1. In the left sidebar, expand the **Whatsapp** menu and select **API Setup**. +2. Click on **Generate access token**. Copy this token and paste it in the **accessToken** channel configuration. +3. Copy the **Phone number ID** and paste it in you **phoneNumberId** channel configuration. + +### Verify Token + +The `verifyToken` is used by Meta to verify that you are the real owner of the provided webhook. + +You can generate any random alphanumerical string for this configuration. Paste it in your **verifyToken** channel configuration. + +### Save Configuration + +_Note: It is important you save your configuration before configuring the webhook, otherwise Whatsapp will be unable to validate the webhook url._ + +1. Edit your bot config. + +```json +{ + // ... other data + "messaging": { + "channels": { + "whatsapp": { + "version": "1.0.0", + "enabled": true, + "phoneNumberId": "phone_number_id", + "accessToken": "your_access_token", + "appId": "app_id", + "appSecret": "your_app_secret", + "verifyToken": "your_verify_token" + } + // ... other channels can also be configured here + } + } +} +``` + +2. Restart Botpress. +3. You should see your webhook endpoint in the console on startup. + +## Webhook Configuration + +To receive messages from Whatsapp, you will need to setup a webhook. + +1. Go to your Meta App. +2. In the left sidebar, expand the **Whatsapp** menu and select **Configuration**. +3. In the **Webhooks** section, click **Add Callback URL**. +4. Set the webhook URL to: `/api/v1/messaging/webhooks/v1//whatsapp`. +5. Copy paste the `verifyToken` you generated earlier. +6. Click on **Verify and save**. Make sure your channel configuration was saved before doing this step, otherwise the webhook validation will fail. +7. In the **Webhook fields** below, subscribe to **messages** to your webhook. diff --git a/packages/channels/README.md b/packages/channels/README.md index 336569e93..c5cbe6859 100644 --- a/packages/channels/README.md +++ b/packages/channels/README.md @@ -9,6 +9,7 @@ - Telegram - Twilio - Vonage +- Whatsapp ## Development diff --git a/packages/channels/src/index.ts b/packages/channels/src/index.ts index c2fd06344..ad9704d7b 100644 --- a/packages/channels/src/index.ts +++ b/packages/channels/src/index.ts @@ -7,3 +7,4 @@ export * from './teams/channel' export * from './telegram/channel' export * from './twilio/channel' export * from './vonage/channel' +export * from './whatsapp/channel' diff --git a/packages/channels/src/whatsapp/README.md b/packages/channels/src/whatsapp/README.md new file mode 100644 index 000000000..c5fbe4a1a --- /dev/null +++ b/packages/channels/src/whatsapp/README.md @@ -0,0 +1,31 @@ +# Whatsapp (v1.0.0) + +### Sending + +| Channels | Whatsapp | Details | +| -------- | :-------: | :-------------------------------- | +| Text | ✅ | | +| Image | ✅ | | +| Choice | ✅ | | +| Dropdown | ✅ | | +| Card | ✅ | | +| Carousel | ✅ | | +| File | ✅ | File sent as URL | +| Audio | ✅ | Audio sent as URL | +| Video | ✅ | Video sent as URL | +| Location | ✅ | Location sent as Google Maps Link | + +### Receiving + +| Channels | Whatsapp | Details | +| ------------- | :-------: | :------ | +| Text | ✅ | | +| Quick Reply | ✅ | | +| Postback | ✅ | | +| Say Something | ✅ | | +| Voice | ❌ | | +| Image | ❌ | | +| File | ❌ | | +| Audio | ❌ | | +| Video | ❌ | | +| Location | ❌ | | diff --git a/packages/channels/src/whatsapp/api.ts b/packages/channels/src/whatsapp/api.ts new file mode 100644 index 000000000..0d3d44f1e --- /dev/null +++ b/packages/channels/src/whatsapp/api.ts @@ -0,0 +1,105 @@ +import crypto from 'crypto' +import express, { Response, Request, NextFunction } from 'express' +import { IncomingMessage } from 'http' +import { ChannelApi, ChannelApiManager, ChannelApiRequest } from '../base/api' +import { IndexChoiceType } from '../base/context' +import { WhatsappService } from './service' +import { WhatsappIncomingMessage, WhatsappPayload } from './whatsapp' + +export class WhatsappApi extends ChannelApi { + async setup(router: ChannelApiManager) { + router.use('/whatsapp', express.json({ verify: this.prepareAuth.bind(this) })) + router.get('/whatsapp', this.handleWebhookVerification.bind(this)) + + router.post('/whatsapp', this.auth.bind(this)) + router.post('/whatsapp', this.handleMessageRequest.bind(this)) + } + + private prepareAuth(_req: IncomingMessage, res: Response, buffer: Buffer, _encoding: string) { + res.locals.authBuffer = Buffer.from(buffer) + } + + private async handleWebhookVerification(req: ChannelApiRequest, res: Response) { + const { config } = this.service.get(req.scope) + + const mode = req.query['hub.mode'] + const token = req.query['hub.verify_token'] + const challenge = req.query['hub.challenge'] + + if (mode === 'subscribe' && token === config.verifyToken) { + res.status(200).send(challenge) + } else { + res.sendStatus(403) + } + } + + private async auth(req: Request, res: Response, next: NextFunction) { + const signature = req.headers['x-hub-signature'] as string + const [, hash] = signature.split('=') + + const { config } = this.service.get(req.params.scope) + const expectedHash = crypto.createHmac('sha1', config.appSecret).update(res.locals.authBuffer).digest('hex') + + if (hash !== expectedHash) { + return res.sendStatus(403) + } else { + next() + } + } + + private async handleMessageRequest(req: ChannelApiRequest, res: Response) { + const payload = req.body as WhatsappPayload + + for (const entry of payload.entry) { + if (entry.changes && entry.changes.length > 0) { + const change = entry.changes[0] + if (change.field && change.field === 'messages') { + const value = change.value + if (value && 'messages' in value && value.messages && value.messages.length > 0) { + for (const message of value.messages) { + await this.receive(req.scope, message) + } + } + } + } + } + res.status(200).send('EVENT_RECEIVED') + } + + private async receive(scope: string, message: WhatsappIncomingMessage) { + if (message && message.id && message.type && message.from) { + const endpoint = this.extractEndpoint(message) + let content: any + if (message.type === 'text' && message.text && message.text.body) { + const index = Number(message.text.body) + content = this.service.handleIndexResponse(scope, index, endpoint.identity, endpoint.sender) || { + type: 'text', + text: message.text.body + } + } else if (message.type === 'interactive' && message.interactive) { + const reply = message.interactive.button_reply || message.interactive.list_reply + if (reply) { + const [type, payload] = reply.id.split('::') + if (type === IndexChoiceType.PostBack) { + content = {type, payload} + } else if (type === IndexChoiceType.SaySomething) { + content = {type, text: payload} + } else if (type === IndexChoiceType.QuickReply) { + content = {type, text: reply.title, payload} + } else if (type === IndexChoiceType.OpenUrl) { + content = {type: IndexChoiceType.SaySomething, text: payload} + } + } + } + await this.service.receive(scope, endpoint, content) + } + } + + private extractEndpoint(message: WhatsappIncomingMessage) { + return { + identity: '*', + sender: message.from, + thread: '*' + } + } +} diff --git a/packages/channels/src/whatsapp/channel.ts b/packages/channels/src/whatsapp/channel.ts new file mode 100644 index 000000000..af68f1d61 --- /dev/null +++ b/packages/channels/src/whatsapp/channel.ts @@ -0,0 +1,28 @@ +import { ChannelTemplate } from '../base/channel' +import { WhatsappApi } from './api' +import { WhatsappConfig, WhatsappConfigSchema } from './config' +import { WhatsappService } from './service' +import { WhatsappStream } from './stream' + +export class WhatsappChannel extends ChannelTemplate< + WhatsappConfig, + WhatsappService, + WhatsappApi, + WhatsappStream +> { + get meta() { + return { + id: '1a01c610-e7eb-4c47-97de-66ab348f473f', + name: 'whatsapp', + version: '1.0.0', + schema: WhatsappConfigSchema, + initiable: true, + lazy: true + } + } + + constructor() { + const service = new WhatsappService() + super(service, new WhatsappApi(service), new WhatsappStream(service)) + } +} diff --git a/packages/channels/src/whatsapp/config.ts b/packages/channels/src/whatsapp/config.ts new file mode 100644 index 000000000..8dc4fef8d --- /dev/null +++ b/packages/channels/src/whatsapp/config.ts @@ -0,0 +1,20 @@ +import Joi from 'joi' +import { ChannelConfig } from '../base/config' + +export interface WhatsappConfig extends ChannelConfig { + appId: string + appSecret: string + verifyToken: string + accessToken: string + phoneNumberId: string + markRead: boolean +} + +export const WhatsappConfigSchema = { + appId: Joi.string().required(), + appSecret: Joi.string().required(), + verifyToken: Joi.string().required(), + accessToken: Joi.string().required(), + phoneNumberId: Joi.string().required(), + markRead: Joi.boolean() +} diff --git a/packages/channels/src/whatsapp/context.ts b/packages/channels/src/whatsapp/context.ts new file mode 100644 index 000000000..dc5596190 --- /dev/null +++ b/packages/channels/src/whatsapp/context.ts @@ -0,0 +1,10 @@ +import { ChannelContext, IndexChoiceOption } from '../base/context' +import { WhatsappState } from './service' +import { WhatsappStream } from './stream' +import { WhatsappOutgoingMessage } from './whatsapp' + +export type WhatsappContext = ChannelContext & { + messages: WhatsappOutgoingMessage[] + stream: WhatsappStream + prepareIndexResponse(scope: string, identity: string, sender: string, options: IndexChoiceOption[]): void +} diff --git a/packages/channels/src/whatsapp/renderers/audio.ts b/packages/channels/src/whatsapp/renderers/audio.ts new file mode 100644 index 000000000..4eac2bfb3 --- /dev/null +++ b/packages/channels/src/whatsapp/renderers/audio.ts @@ -0,0 +1,14 @@ +import { AudioRenderer } from '../../base/renderers/audio' +import { AudioContent } from '../../content/types' +import { WhatsappContext } from '../context' + +export class WhatsappAudioRenderer extends AudioRenderer { + renderAudio(context: WhatsappContext, payload: AudioContent) { + context.messages.push({ + type: 'audio', + audio: { + link: payload.audio + } + }) + } +} diff --git a/packages/channels/src/whatsapp/renderers/carousel.ts b/packages/channels/src/whatsapp/renderers/carousel.ts new file mode 100644 index 000000000..d212a3965 --- /dev/null +++ b/packages/channels/src/whatsapp/renderers/carousel.ts @@ -0,0 +1,170 @@ + +import { IndexChoiceOption, IndexChoiceType } from '../../base/context' +import { CarouselRenderer, CarouselContext } from '../../base/renderers/carousel' +import { ActionOpenURL, ActionPostback, ActionSaySomething, CardContent, CarouselContent } from '../../content/types' +import { WhatsappContext } from '../context' +import { WhatsappButton } from '../whatsapp' + +type Context = CarouselContext & { + buttons: WhatsappButton[] + options: IndexChoiceOption[] + index: number +} + +export class WhatsappCarouselRenderer extends CarouselRenderer { + startRender(context: Context, carousel: CarouselContent) { + context.options = [] + } + + startRenderCard(context: Context, card: CardContent) { + context.buttons = [] + } + + renderButtonUrl(context: Context, button: ActionOpenURL) { + context.buttons.push({ + type: 'reply', + reply: { + id: `${IndexChoiceType.OpenUrl}::${button.url}`.substring(0, 256), + title: button.title.substring(0, 20) + } + }) + } + + renderButtonPostback(context: Context, button: ActionPostback) { + context.buttons.push({ + type: 'reply', + reply: { + id: `${IndexChoiceType.PostBack}::${button.payload}`.substring(0, 256), + title: button.title.substring(0, 20) + } + }) + } + + renderButtonSay(context: Context, button: ActionSaySomething) { + context.buttons.push({ + type: 'reply', + reply: { + id: `${IndexChoiceType.SaySomething}::${button.text}`.substring(0, 256), + title: button.title.substring(0, 20) + } + }) + } + + endRenderCard(context: Context, card: CardContent) { + let message: any + if (!context.buttons.length) { + const text = card.subtitle ? `${card.title}\n\n${card.subtitle}` : card.title + if (card.image) { + message = { + type: 'image', + image: { + link: card.image, + caption: text.substring(0, 1024) + } + } + } else { + message = { + type: 'text', + text: { + preview_url: true, + body: text.substring(0, 4096) + } + } + } + } else if (context.buttons.length <= 3) { + message = { + type: 'interactive', + interactive: { + type: 'button', + body: { + text: card.title.substring(0, 1024) + }, + action: { + buttons: context.buttons + } + } + } + if (card.image) { + message.interactive.header = { + type: 'image', + image: { + link: card.image + } + } + } + if (card.subtitle) { + message.interactive.footer = { + text: card.subtitle.substring(0, 60) + } + } + } else if (!card.image && context.buttons.length <= 10) { + message = { + type: 'interactive', + interactive: { + type: 'list', + body: { + text: card.title.substring(0, 4096) + }, + action: { + button: 'Select...', + sections: [ + { + rows: context.buttons.map((button) => ({ + id: button.reply.id.substring(0, 200), + title: button.reply.title.substring(0, 24) + })) + } + ] + } + } + } + if (card.subtitle) { + message.interactive.footer = { + text: card.subtitle.substring(0, 60) + } + } + } else { + const text = `${card.title}\n\n${card.subtitle || ''}\n\n${context.buttons + .map(({ reply }, index) => `${index + context.options.length + 1}. ${reply.title}`) + .join('\n')}` + + if (card.image) { + message = { + type: 'image', + image: { + link: card.image, + caption: text.substring(0, 1024) + } + } + } else { + message = { + type: 'text', + text: { + preview_url: false, + body: text.substring(0, 4096) + } + } + } + context.options.push(...context.buttons.map((button) => { + const [type, value] = button.reply.id.split('::') as [IndexChoiceType, string] + return { + type: type === IndexChoiceType.OpenUrl ? IndexChoiceType.SaySomething : type, + title: button.reply.title, + value + } + })) + } + context.channel.messages.push(message) + } + + endRender(context: Context, carousel: CarouselContent) { + if (context.options.length) { + context.channel.prepareIndexResponse( + context.channel.scope, + context.channel.identity, + context.channel.sender, + context.options + ) + } + } +} diff --git a/packages/channels/src/whatsapp/renderers/choices.ts b/packages/channels/src/whatsapp/renderers/choices.ts new file mode 100644 index 000000000..dcf66cc9f --- /dev/null +++ b/packages/channels/src/whatsapp/renderers/choices.ts @@ -0,0 +1,70 @@ +import { IndexChoiceType } from '../../base/context' +import { ChoicesRenderer } from '../../base/renderers/choices' +import { ChoiceContent, ChoiceOption } from '../../content/types' +import { WhatsappContext } from '../context' + +export class WhatsappChoicesRenderer extends ChoicesRenderer { + renderChoice(context: WhatsappContext, payload: ChoiceContent): void { + if (payload.choices.length && context.messages.length) { + if (payload.choices.length <= 3) { + context.messages[0] = { + type: 'interactive', + interactive: { + type: 'button', + body: { + text: payload.text.substring(0, 1024) + }, + action: { + buttons: payload.choices.map((choice) => ({ + type: 'reply', + reply: { + id: `${IndexChoiceType.QuickReply}::${choice.value}`, + title: choice.title.substring(0, 20) + } + })) + } + } + } + } else if (payload.choices.length <= 10) { + context.messages[0] = { + type: 'interactive', + interactive: { + type: 'list', + body: { + text: payload.text.substring(0, 4096) + }, + action: { + button: 'Select...', + sections: [ + { + rows: payload.choices.map((choice) => ({ + id: `${IndexChoiceType.QuickReply}::${choice.value}`, + title: choice.title.substring(0, 24) + })) + } + ] + } + } + } + } else { + const text = `${payload.text}\n\n${payload.choices + .map(({ title }, index) => `${index + 1}. ${title}`) + .join('\n')}` + + context.messages[0] = { + type: 'text', + text: { + preview_url: false, + body: text.substring(0, 4096) + } + } + context.prepareIndexResponse( + context.scope, + context.identity, + context.sender, + payload.choices.map((choice: ChoiceOption) => ({...choice, type: IndexChoiceType.QuickReply })) + ) + } + } + } +} diff --git a/packages/channels/src/whatsapp/renderers/file.ts b/packages/channels/src/whatsapp/renderers/file.ts new file mode 100644 index 000000000..eb1c720ad --- /dev/null +++ b/packages/channels/src/whatsapp/renderers/file.ts @@ -0,0 +1,15 @@ +import { FileRenderer } from '../../base/renderers/file' +import { FileContent } from '../../content/types' +import { WhatsappContext } from '../context' + +export class WhatsappFileRenderer extends FileRenderer { + renderFile(context: WhatsappContext, payload: FileContent) { + context.messages.push({ + type: 'document', + document: { + link: payload.file, + caption: payload.title ? payload.title.substring(0, 1024) : '' + } + }) + } +} diff --git a/packages/channels/src/whatsapp/renderers/image.ts b/packages/channels/src/whatsapp/renderers/image.ts new file mode 100644 index 000000000..076743cab --- /dev/null +++ b/packages/channels/src/whatsapp/renderers/image.ts @@ -0,0 +1,15 @@ +import { ImageRenderer } from '../../base/renderers/image' +import { ImageContent } from '../../content/types' +import { WhatsappContext } from '../context' + +export class WhatsappImageRenderer extends ImageRenderer { + renderImage(context: WhatsappContext, payload: ImageContent): void { + context.messages.push({ + type: 'image', + image: { + link: payload.image, + caption: payload.title ? payload.title.substring(0, 1024) : '' + } + }) + } +} diff --git a/packages/channels/src/whatsapp/renderers/index.ts b/packages/channels/src/whatsapp/renderers/index.ts new file mode 100644 index 000000000..3898bc66a --- /dev/null +++ b/packages/channels/src/whatsapp/renderers/index.ts @@ -0,0 +1,19 @@ +import { WhatsappAudioRenderer } from './audio' +import { WhatsappCarouselRenderer } from './carousel' +import { WhatsappChoicesRenderer } from './choices' +import { WhatsappFileRenderer } from './file' +import { WhatsappImageRenderer } from './image' +import { WhatsappLocationRenderer } from './location' +import { WhatsappTextRenderer } from './text' +import { WhatsappVideoRenderer } from './video' + +export const WhatsappRenderers = [ + new WhatsappTextRenderer(), + new WhatsappImageRenderer(), + new WhatsappCarouselRenderer(), + new WhatsappChoicesRenderer(), + new WhatsappFileRenderer(), + new WhatsappAudioRenderer(), + new WhatsappVideoRenderer(), + new WhatsappLocationRenderer() +] diff --git a/packages/channels/src/whatsapp/renderers/location.ts b/packages/channels/src/whatsapp/renderers/location.ts new file mode 100644 index 000000000..d7344e7e1 --- /dev/null +++ b/packages/channels/src/whatsapp/renderers/location.ts @@ -0,0 +1,17 @@ +import { LocationRenderer } from '../../base/renderers/location' +import { LocationContent } from '../../content/types' +import { WhatsappContext } from '../context' + +export class WhatsappLocationRenderer extends LocationRenderer { + renderLocation(context: WhatsappContext, payload: LocationContent) { + context.messages.push({ + type: 'location', + location: { + longitude: payload.longitude, + latitude: payload.latitude, + name: payload.title, + address: payload.address + } + }) + } +} diff --git a/packages/channels/src/whatsapp/renderers/text.ts b/packages/channels/src/whatsapp/renderers/text.ts new file mode 100644 index 000000000..a9fc3eb35 --- /dev/null +++ b/packages/channels/src/whatsapp/renderers/text.ts @@ -0,0 +1,15 @@ +import { TextRenderer } from '../../base/renderers/text' +import { TextContent } from '../../content/types' +import { WhatsappContext } from '../context' + +export class WhatsappTextRenderer extends TextRenderer { + renderText(context: WhatsappContext, payload: TextContent): void { + context.messages.push({ + type: 'text', + text: { + preview_url: true, + body: payload.text.substring(0, 4096) + } + }) + } +} diff --git a/packages/channels/src/whatsapp/renderers/video.ts b/packages/channels/src/whatsapp/renderers/video.ts new file mode 100644 index 000000000..2ed290db8 --- /dev/null +++ b/packages/channels/src/whatsapp/renderers/video.ts @@ -0,0 +1,15 @@ +import { VideoRenderer } from '../../base/renderers/video' +import { VideoContent } from '../../content/types' +import { WhatsappContext } from '../context' + +export class WhatsappVideoRenderer extends VideoRenderer { + renderVideo(context: WhatsappContext, payload: VideoContent) { + context.messages.push({ + type: 'video', + video: { + link: payload.video, + caption: payload.title ? payload.title.substring(0, 1024) : '' + } + }) + } +} diff --git a/packages/channels/src/whatsapp/senders/common.ts b/packages/channels/src/whatsapp/senders/common.ts new file mode 100644 index 000000000..67662166a --- /dev/null +++ b/packages/channels/src/whatsapp/senders/common.ts @@ -0,0 +1,10 @@ +import { CommonSender } from '../../base/senders/common' +import { WhatsappContext } from '../context' + +export class WhatsappCommonSender extends CommonSender { + async send(context: WhatsappContext) { + for (const message of context.messages) { + await context.stream.sendMessage(context.scope, context, message) + } + } +} diff --git a/packages/channels/src/whatsapp/senders/index.ts b/packages/channels/src/whatsapp/senders/index.ts new file mode 100644 index 000000000..93e57b476 --- /dev/null +++ b/packages/channels/src/whatsapp/senders/index.ts @@ -0,0 +1,3 @@ +import { WhatsappCommonSender } from './common' + +export const WhatsappSenders = [new WhatsappCommonSender()] diff --git a/packages/channels/src/whatsapp/service.ts b/packages/channels/src/whatsapp/service.ts new file mode 100644 index 000000000..f07fbd95a --- /dev/null +++ b/packages/channels/src/whatsapp/service.ts @@ -0,0 +1,12 @@ +import { ChannelService, ChannelState } from '../base/service' +import { WhatsappConfig } from './config' + +export interface WhatsappState extends ChannelState {} + +export class WhatsappService extends ChannelService { + async create(scope: string, config: WhatsappConfig) { + return { + config + } + } +} diff --git a/packages/channels/src/whatsapp/stream.ts b/packages/channels/src/whatsapp/stream.ts new file mode 100644 index 000000000..004c87560 --- /dev/null +++ b/packages/channels/src/whatsapp/stream.ts @@ -0,0 +1,113 @@ +import axios from 'axios' +import { ChannelTestError, Endpoint } from '..' +import { ChannelContext } from '../base/context' +import { CardToCarouselRenderer } from '../base/renderers/card' +import { DropdownToChoicesRenderer } from '../base/renderers/dropdown' +import { ChannelReceiveEvent, ChannelTestEvent } from '../base/service' +import { ChannelStream } from '../base/stream' +import { WhatsappContext } from './context' +import { WhatsappRenderers } from './renderers' +import { WhatsappSenders } from './senders' +import { WhatsappService } from './service' +import { WhatsappPhoneNumberInfo } from './whatsapp' + +const GRAPH_URL = 'https://graph.facebook.com/v20.0' + +export class WhatsappStream extends ChannelStream { + get renderers() { + return [new CardToCarouselRenderer(), new DropdownToChoicesRenderer(), ...WhatsappRenderers] + } + + get senders() { + return WhatsappSenders + } + + async setup() { + await super.setup() + + this.service.on('receive', this.handleReceive.bind(this)) + this.service.on('test', this.handleTest.bind(this)) + } + + private async handleTest({ scope }: ChannelTestEvent) { + const { config } = this.service.get(scope) + + let info: WhatsappPhoneNumberInfo + try { + info = await this.fetchPhoneNumberById(scope) + } catch { + throw new ChannelTestError('unable to reach whatsapp with the provided access token', 'whatsapp', 'accessToken') + } + + if (info.id !== config.phoneNumberId) { + throw new ChannelTestError('phone number id does not match provided access token', 'whatsapp', 'phoneNumberId') + } + + try { + await this.fetchAppInfo(scope) + } catch { + throw new ChannelTestError('app id does not match provided access token', 'whatsapp', 'appId') + } + } + + protected async handleReceive({ scope, endpoint, content }: ChannelReceiveEvent) { + const { config } = this.service.get(scope) + + if (config.markRead) { + await this.markRead(scope, endpoint, content) + } + } + + public async sendMessage(scope: string, endpoint: Endpoint, message: any) { + await this.post(scope, endpoint, { message }) + } + + public async markRead(scope: string, endpoint: Endpoint, message: any) { + await this.post(scope, endpoint, { message: { status: 'read', message_id: message.id }}) + } + + private async post(scope: string, endpoint: Endpoint, data: any) { + const { config } = this.service.get(scope) + + await axios.post( + `${GRAPH_URL}/${config.phoneNumberId}/messages`, + { + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: endpoint.sender, + ...data.message + }, + { + headers: { + Authorization: `Bearer ${config.accessToken}` + } + } + ) + } + + private async fetchPhoneNumberById(scope: string): Promise { + const { config } = this.service.get(scope) + + const response = await axios.get(`${GRAPH_URL}/${config.phoneNumberId}`, { + headers: { + Authorization: `Bearer ${config.accessToken}` + } + }) + return response.data + } + + private async fetchAppInfo(scope: string): Promise { + const { config } = this.service.get(scope) + + return (await axios.get(`${GRAPH_URL}/${config.appId}`, { params: { access_token: config.accessToken } })).data + } + + protected async getContext(base: ChannelContext): Promise { + return { + ...base, + stream: this, + messages: [], + prepareIndexResponse: this.service.prepareIndexResponse.bind(this.service) + } + } +} diff --git a/packages/channels/src/whatsapp/whatsapp.ts b/packages/channels/src/whatsapp/whatsapp.ts new file mode 100644 index 000000000..3ede456fb --- /dev/null +++ b/packages/channels/src/whatsapp/whatsapp.ts @@ -0,0 +1,131 @@ +export interface WhatsappPhoneNumberInfo { + verified_name: string + code_verification_status: string + display_phone_number: string + quality_rating: string + platform_type: string + throughput: { + level: string + } + id: string +} + +export interface WhatsappContact { + profile: { + name: string + } + wa_id: string +} + +export interface WhatsappPayload { + object: string + entry: WhatsappEntry[] +} + +export interface WhatsappEntry { + id: string + changes: WhatsappChange[] +} + +export interface WhatsappIncomingMessage { + from: string + id: string + timestamp: string + type: string + text?: { + preview_url?: boolean + body: string + } + interactive?: { + type: string + button_reply?: { + id: string + title: string + } + list_reply?: { + id: string + title: string + } + } +} + +export interface WhatsappChange { + value: { + messaging_product: string + metadata: { + display_phone_number: string + phone_number_id: string + } + contacts: WhatsappContact[] + messages?: WhatsappIncomingMessage[] + } + field: string +} + + +export interface WhatsappText { + preview_url?: boolean + body: string +} + +export interface WhatsappMedia { + link: string + caption?: string +} + +export interface WhatsappLocation { + longitude: number + latitude: number + name?: string + address?: string +} + +export interface WhatsappButton { + type: 'reply' + reply: { + id: string + title: string + } +} + +export interface WhatsappRow { + id: string + title: string +} + +export interface WhatsappSection { + rows: WhatsappRow[] +} + +export interface WhatsappInteractive { + type: 'button' | 'list' + header?: { + type: 'text' | 'image' | 'video' | 'document' + text?: string + image?: WhatsappMedia + video?: WhatsappMedia + document?: WhatsappMedia + } + body?: { + text: string + } + footer?: { + text: string + } + action: { + button?: string + buttons?: WhatsappButton[] + sections?: WhatsappSection[] + } +} + +export interface WhatsappOutgoingMessage { + type: 'text' | 'image' | 'audio' | 'video' | 'document' | 'location' | 'interactive' + text?: WhatsappText + image?: WhatsappMedia + audio?: WhatsappMedia + video?: WhatsappMedia + document?: WhatsappMedia + location?: WhatsappLocation + interactive?: WhatsappInteractive +} diff --git a/packages/client/package.json b/packages/client/package.json index ec8ec1187..ca46ef486 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -21,7 +21,8 @@ "teams", "telegram", "twilio", - "vonage" + "vonage", + "whatsapp" ], "homepage": "https://botpress.com/docs", "scripts": { diff --git a/packages/server/src/channels/service.ts b/packages/server/src/channels/service.ts index 118a004ed..ff46d420b 100644 --- a/packages/server/src/channels/service.ts +++ b/packages/server/src/channels/service.ts @@ -7,7 +7,8 @@ import { TeamsChannel, TelegramChannel, TwilioChannel, - VonageChannel + VonageChannel, + WhatsappChannel } from '@botpress/messaging-channels' import { MessengerChannel as MessengerChannelLegacy, @@ -43,7 +44,8 @@ export class ChannelService extends Service { new TelegramChannel(), new TwilioChannel(), new SmoochChannel(), - new VonageChannel() + new VonageChannel(), + new WhatsappChannel() ] if (yn(process.env.ENABLE_LEGACY_CHANNELS)) {