From f6cb184196c1eae2aa9c8e854368ef8245eba236 Mon Sep 17 00:00:00 2001 From: Varun Seth Date: Thu, 2 Nov 2023 16:32:58 +0530 Subject: [PATCH 01/33] whatsapp interfaces --- packages/channels/src/whatsapp/whatsapp.ts | 74 ++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 packages/channels/src/whatsapp/whatsapp.ts diff --git a/packages/channels/src/whatsapp/whatsapp.ts b/packages/channels/src/whatsapp/whatsapp.ts new file mode 100644 index 000000000..14b1ab66a --- /dev/null +++ b/packages/channels/src/whatsapp/whatsapp.ts @@ -0,0 +1,74 @@ +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 WhatsappPayload { + object: string + entry: WhatsappEntry[] +} + +export interface WhatsappEntry { + id: string + changes: WhatsappChange[] +} + +export interface WhatsappChange { + value: { + messaging_product: string + metadata: { + display_phone_number: string + phone_number_id: string + } + contacts: WhatsappContact[] + messages?: WhatsappMessage[] + statuses?: WhatsappStatus[] + } + field: string +} + +export interface WhatsappContact { + profile: { + name: string + } + wa_id: string +} + +export interface WhatsappMessage { + from: string + id: string + timestamp: string + type: string + text?: WhatsappText +} + +export interface WhatsappStatus { + id: string + status: string + timestamp: string + recipient_id: string + conversation?: { + id: string + expiration_timestamp: string + origin: { + type: string + } + } + pricing?: { + billable: boolean + pricing_model: string + category: string + } +} + +export interface WhatsappText { + preview_url?: boolean + body: string +} From e16b168bf980cc5666c41790e5fce4f8fdb6c96d Mon Sep 17 00:00:00 2001 From: Varun Seth Date: Thu, 2 Nov 2023 16:33:50 +0530 Subject: [PATCH 02/33] whatsapp channel config --- packages/channels/src/whatsapp/config.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 packages/channels/src/whatsapp/config.ts diff --git a/packages/channels/src/whatsapp/config.ts b/packages/channels/src/whatsapp/config.ts new file mode 100644 index 000000000..415ed3e0e --- /dev/null +++ b/packages/channels/src/whatsapp/config.ts @@ -0,0 +1,18 @@ +import Joi from 'joi' +import { ChannelConfig } from '../base/config' + +export interface WhatsappConfig extends ChannelConfig { + appId: string + appSecret: string + verifyToken: string + accessToken: string + phoneNumberId: string +} + +export const WhatsappConfigSchema = { + appId: Joi.string().required(), + appSecret: Joi.string().required(), + verifyToken: Joi.string().required(), + accessToken: Joi.string().required(), + phoneNumberId: Joi.string().required() +} From d2872f0187b1c89c9186e6a73544aea3877c1ea1 Mon Sep 17 00:00:00 2001 From: Varun Seth Date: Thu, 2 Nov 2023 16:34:30 +0530 Subject: [PATCH 03/33] whatsapp channel service --- packages/channels/src/whatsapp/service.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 packages/channels/src/whatsapp/service.ts 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 + } + } +} From 5720a5bc6128be0246098b4e6a2dfea43ce4e6d0 Mon Sep 17 00:00:00 2001 From: Varun Seth Date: Thu, 2 Nov 2023 16:36:16 +0530 Subject: [PATCH 04/33] whatsapp channel stream --- packages/channels/src/whatsapp/stream.ts | 109 +++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 packages/channels/src/whatsapp/stream.ts diff --git a/packages/channels/src/whatsapp/stream.ts b/packages/channels/src/whatsapp/stream.ts new file mode 100644 index 000000000..e89d7feaf --- /dev/null +++ b/packages/channels/src/whatsapp/stream.ts @@ -0,0 +1,109 @@ +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/v18.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) { + 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, + messages: [], + stream: this, + prepareIndexResponse: this.service.prepareIndexResponse.bind(this.service) + } + } +} From da3d2cf64e79e5c4fba713ec17776d46a337dfe8 Mon Sep 17 00:00:00 2001 From: Varun Seth Date: Thu, 2 Nov 2023 16:36:42 +0530 Subject: [PATCH 05/33] whatsapp channel context --- packages/channels/src/whatsapp/context.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 packages/channels/src/whatsapp/context.ts diff --git a/packages/channels/src/whatsapp/context.ts b/packages/channels/src/whatsapp/context.ts new file mode 100644 index 000000000..382ae215a --- /dev/null +++ b/packages/channels/src/whatsapp/context.ts @@ -0,0 +1,9 @@ +import { ChannelContext, IndexChoiceOption} from '../base/context' +import { WhatsappState } from './service' +import { WhatsappStream } from './stream' + +export type WhatsappContext = ChannelContext & { + messages: any[] + stream: WhatsappStream + prepareIndexResponse(scope: string, identity: string, sender: string, options: IndexChoiceOption[]): void +} From 6c8c7147e6f8957abb7ecee2c79ddd4afd8bf6f5 Mon Sep 17 00:00:00 2001 From: Varun Seth Date: Thu, 2 Nov 2023 16:37:13 +0530 Subject: [PATCH 06/33] whatsapp channel api --- packages/channels/src/whatsapp/api.ts | 89 +++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 packages/channels/src/whatsapp/api.ts diff --git a/packages/channels/src/whatsapp/api.ts b/packages/channels/src/whatsapp/api.ts new file mode 100644 index 000000000..b1326b4f0 --- /dev/null +++ b/packages/channels/src/whatsapp/api.ts @@ -0,0 +1,89 @@ +import crypto from 'crypto' +import express, { Response, Request, NextFunction } from 'express' +import { IncomingMessage } from 'http' +import { ChannelApi, ChannelApiManager, ChannelApiRequest } from '../base/api' +import { WhatsappService } from './service' +import { WhatsappMessage, 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: WhatsappMessage) { + if (message) { + if (message.id && message.from) { + if (message.type === 'text' && message.text && message.text.body) { + await this.service.receive(scope, this.extractEndpoint(message), { + id: message.id, + type: 'text', + text: message.text.body + }) + } + } + } + } + + private extractEndpoint(message: WhatsappMessage) { + return { + identity: '*', + sender: message.from, + thread: '*' + } + } +} From 68c71c1a98ab67a638cc642bbb5753ff8103005e Mon Sep 17 00:00:00 2001 From: Varun Seth Date: Thu, 2 Nov 2023 16:37:26 +0530 Subject: [PATCH 07/33] whatsapp channel --- packages/channels/src/whatsapp/channel.ts | 28 +++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 packages/channels/src/whatsapp/channel.ts 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)) + } +} From c9399af37b07f76b60c7e9424acdc0ea3b4e1ffd Mon Sep 17 00:00:00 2001 From: Varun Seth Date: Thu, 2 Nov 2023 16:37:52 +0530 Subject: [PATCH 08/33] whatsapp renderers --- .../channels/src/whatsapp/renderers/audio.ts | 14 ++++++++++++++ .../channels/src/whatsapp/renderers/file.ts | 15 +++++++++++++++ .../channels/src/whatsapp/renderers/image.ts | 15 +++++++++++++++ .../channels/src/whatsapp/renderers/index.ts | 19 +++++++++++++++++++ .../src/whatsapp/renderers/location.ts | 17 +++++++++++++++++ .../channels/src/whatsapp/renderers/text.ts | 15 +++++++++++++++ .../channels/src/whatsapp/renderers/video.ts | 15 +++++++++++++++ 7 files changed, 110 insertions(+) create mode 100644 packages/channels/src/whatsapp/renderers/audio.ts create mode 100644 packages/channels/src/whatsapp/renderers/file.ts create mode 100644 packages/channels/src/whatsapp/renderers/image.ts create mode 100644 packages/channels/src/whatsapp/renderers/index.ts create mode 100644 packages/channels/src/whatsapp/renderers/location.ts create mode 100644 packages/channels/src/whatsapp/renderers/text.ts create mode 100644 packages/channels/src/whatsapp/renderers/video.ts 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/file.ts b/packages/channels/src/whatsapp/renderers/file.ts new file mode 100644 index 000000000..b62ec880c --- /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 + } + }) + } +} diff --git a/packages/channels/src/whatsapp/renderers/image.ts b/packages/channels/src/whatsapp/renderers/image.ts new file mode 100644 index 000000000..ed3e6df72 --- /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 + } + }) + } +} diff --git a/packages/channels/src/whatsapp/renderers/index.ts b/packages/channels/src/whatsapp/renderers/index.ts new file mode 100644 index 000000000..2cda44053 --- /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..319562f06 --- /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: false, + body: payload.text + } + }) + } +} diff --git a/packages/channels/src/whatsapp/renderers/video.ts b/packages/channels/src/whatsapp/renderers/video.ts new file mode 100644 index 000000000..a867172de --- /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 + } + }) + } +} From 6e5176c405b3f22d695b75233d50077e70a62792 Mon Sep 17 00:00:00 2001 From: Varun Seth Date: Thu, 2 Nov 2023 16:38:07 +0530 Subject: [PATCH 09/33] whatsapp senders --- packages/channels/src/whatsapp/senders/common.ts | 10 ++++++++++ packages/channels/src/whatsapp/senders/index.ts | 3 +++ 2 files changed, 13 insertions(+) create mode 100644 packages/channels/src/whatsapp/senders/common.ts create mode 100644 packages/channels/src/whatsapp/senders/index.ts 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()] From 4d8db875ed128f318e62ec0febb6bdf0244b6285 Mon Sep 17 00:00:00 2001 From: Varun Seth Date: Thu, 2 Nov 2023 16:38:22 +0530 Subject: [PATCH 10/33] updated index.ts --- packages/channels/src/index.ts | 1 + 1 file changed, 1 insertion(+) 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' From 658e741b78476ddf7ea63eae670e89eb0f530659 Mon Sep 17 00:00:00 2001 From: Varun Seth Date: Thu, 2 Nov 2023 16:38:42 +0530 Subject: [PATCH 11/33] added whatsapp keyword in client package.json --- packages/client/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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": { From c62c1dcbe2778da3ec20bfb0a519abe4b5eced50 Mon Sep 17 00:00:00 2001 From: Varun Seth Date: Thu, 2 Nov 2023 16:39:16 +0530 Subject: [PATCH 12/33] added WhatsappChannel in server channel service --- packages/server/src/channels/service.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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)) { From 40eca81bedeb3c5a555e1823bfedc3545d3e441e Mon Sep 17 00:00:00 2001 From: Varun Seth Date: Fri, 3 Nov 2023 17:20:14 +0530 Subject: [PATCH 13/33] add optional markRead config for whatsapp channel --- packages/channels/src/whatsapp/config.ts | 4 +++- packages/channels/src/whatsapp/stream.ts | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/channels/src/whatsapp/config.ts b/packages/channels/src/whatsapp/config.ts index 415ed3e0e..8dc4fef8d 100644 --- a/packages/channels/src/whatsapp/config.ts +++ b/packages/channels/src/whatsapp/config.ts @@ -7,6 +7,7 @@ export interface WhatsappConfig extends ChannelConfig { verifyToken: string accessToken: string phoneNumberId: string + markRead: boolean } export const WhatsappConfigSchema = { @@ -14,5 +15,6 @@ export const WhatsappConfigSchema = { appSecret: Joi.string().required(), verifyToken: Joi.string().required(), accessToken: Joi.string().required(), - phoneNumberId: Joi.string().required() + phoneNumberId: Joi.string().required(), + markRead: Joi.boolean() } diff --git a/packages/channels/src/whatsapp/stream.ts b/packages/channels/src/whatsapp/stream.ts index e89d7feaf..6f9715468 100644 --- a/packages/channels/src/whatsapp/stream.ts +++ b/packages/channels/src/whatsapp/stream.ts @@ -51,7 +51,11 @@ export class WhatsappStream extends ChannelStream Date: Sat, 11 Nov 2023 00:07:52 +0530 Subject: [PATCH 14/33] add choice renderer & handled interactive messages --- packages/channels/src/whatsapp/api.ts | 23 +++++++-- .../src/whatsapp/renderers/choices.ts | 49 +++++++++++++++++++ .../channels/src/whatsapp/renderers/index.ts | 4 +- packages/channels/src/whatsapp/whatsapp.ts | 37 +++++--------- 4 files changed, 82 insertions(+), 31 deletions(-) create mode 100644 packages/channels/src/whatsapp/renderers/choices.ts diff --git a/packages/channels/src/whatsapp/api.ts b/packages/channels/src/whatsapp/api.ts index b1326b4f0..79371c672 100644 --- a/packages/channels/src/whatsapp/api.ts +++ b/packages/channels/src/whatsapp/api.ts @@ -66,13 +66,26 @@ export class WhatsappApi extends ChannelApi { } private async receive(scope: string, message: WhatsappMessage) { - if (message) { - if (message.id && message.from) { - if (message.type === 'text' && message.text && message.text.body) { + if (message && message.id && message.type && message.from) { + if (message.type === 'text' && message.text && message.text.body) { + await this.service.receive(scope, this.extractEndpoint(message), { + id: message.id, + type: 'text', + text: message.text.body + }) + } else if (message.type === 'interactive' && message.interactive) { + let payload + if (message.interactive.type === 'button_reply' && message.interactive.button_reply) { + payload = message.interactive.button_reply + } else if (message.interactive.type === 'list_reply' &&message.interactive.list_reply) { + payload = message.interactive.list_reply + } + if (payload?.id.startsWith('reply::')) { await this.service.receive(scope, this.extractEndpoint(message), { id: message.id, - type: 'text', - text: message.text.body + type: 'quick_reply', + text: payload.title, + payload: payload.id.replace('reply::', '') }) } } diff --git a/packages/channels/src/whatsapp/renderers/choices.ts b/packages/channels/src/whatsapp/renderers/choices.ts new file mode 100644 index 000000000..5c326f685 --- /dev/null +++ b/packages/channels/src/whatsapp/renderers/choices.ts @@ -0,0 +1,49 @@ +import { ChoicesRenderer } from '../../base/renderers/choices' +import { ChoiceContent } from '../../content/types' +import { WhatsappContext } from '../context' + +export class WhatsappChoicesRenderer extends ChoicesRenderer { + renderChoice(context: WhatsappContext, payload: ChoiceContent): void { + if (payload.choices.length <= 3) { + context.messages[0] = { + type: 'interactive', + interactive: { + type: 'button', + body: { + text: payload.text + }, + action: { + buttons: payload.choices.map((choice) => ({ + type: 'reply', + reply: { + id: `reply::${choice.value}`, + title: choice.title + } + })) + } + } + } + } else if (payload.choices.length <= 10) { + context.messages[0] = { + type: 'interactive', + interactive: { + type: 'list', + body: { + text: payload.text + }, + action: { + button: 'Browse', + sections: [ + { + rows: payload.choices.map((choice) => ({ + id: `reply::${choice.value}`, + title: choice.title + })) + } + ] + } + } + } + } + } +} diff --git a/packages/channels/src/whatsapp/renderers/index.ts b/packages/channels/src/whatsapp/renderers/index.ts index 2cda44053..1e50a730a 100644 --- a/packages/channels/src/whatsapp/renderers/index.ts +++ b/packages/channels/src/whatsapp/renderers/index.ts @@ -1,6 +1,6 @@ import { WhatsappAudioRenderer } from './audio' // import { WhatsappCarouselRenderer } from './carousel' -// import { WhatsappChoicesRenderer } from './choices' +import { WhatsappChoicesRenderer } from './choices' import { WhatsappFileRenderer } from './file' import { WhatsappImageRenderer } from './image' import { WhatsappLocationRenderer } from './location' @@ -11,7 +11,7 @@ export const WhatsappRenderers = [ new WhatsappTextRenderer(), new WhatsappImageRenderer(), // new WhatsappCarouselRenderer(), - // new WhatsappChoicesRenderer(), + new WhatsappChoicesRenderer(), new WhatsappFileRenderer(), new WhatsappAudioRenderer(), new WhatsappVideoRenderer(), diff --git a/packages/channels/src/whatsapp/whatsapp.ts b/packages/channels/src/whatsapp/whatsapp.ts index 14b1ab66a..1763928dc 100644 --- a/packages/channels/src/whatsapp/whatsapp.ts +++ b/packages/channels/src/whatsapp/whatsapp.ts @@ -29,7 +29,6 @@ export interface WhatsappChange { } contacts: WhatsappContact[] messages?: WhatsappMessage[] - statuses?: WhatsappStatus[] } field: string } @@ -46,29 +45,19 @@ export interface WhatsappMessage { id: string timestamp: string type: string - text?: WhatsappText -} - -export interface WhatsappStatus { - id: string - status: string - timestamp: string - recipient_id: string - conversation?: { - id: string - expiration_timestamp: string - origin: { - type: string - } + text?: { + preview_url?: boolean + body: string } - pricing?: { - billable: boolean - pricing_model: string - category: string + interactive?: { + type: string + button_reply?: { + id: string + title: string + } + list_reply?: { + id: string + title: string + } } } - -export interface WhatsappText { - preview_url?: boolean - body: string -} From b0e4ed07aa4f8308ebfba61b803f2e19825027a2 Mon Sep 17 00:00:00 2001 From: Varun Seth <44404326+ivarunseth@users.noreply.github.com> Date: Wed, 19 Jun 2024 00:36:21 +0530 Subject: [PATCH 15/33] Update README.md --- packages/channels/README.md | 1 + 1 file changed, 1 insertion(+) 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 From 4a6b615d9d70294c58a5cc99b5c0de2aac167c81 Mon Sep 17 00:00:00 2001 From: Varun Seth <44404326+ivarunseth@users.noreply.github.com> Date: Wed, 19 Jun 2024 00:37:16 +0530 Subject: [PATCH 16/33] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) 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 From 728a4ba81e9021aa0c48af4e412db98ce9630af8 Mon Sep 17 00:00:00 2001 From: Varun Seth <44404326+ivarunseth@users.noreply.github.com> Date: Wed, 19 Jun 2024 00:46:47 +0530 Subject: [PATCH 17/33] Create README.md --- packages/channels/src/whatsapp/README.md | 31 ++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 packages/channels/src/whatsapp/README.md diff --git a/packages/channels/src/whatsapp/README.md b/packages/channels/src/whatsapp/README.md new file mode 100644 index 000000000..f33a94a86 --- /dev/null +++ b/packages/channels/src/whatsapp/README.md @@ -0,0 +1,31 @@ +# Whatsapp (v1.0.0) + +### Sending + +| Channels | Messenger | 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 | Messenger | Details | +| ------------- | :-------: | :------ | +| Text | ✅ | | +| Quick Reply | ✅ | | +| Postback | ✅ | | +| Say Something | ✅ | | +| Voice | ❌ | | +| Image | ❌ | | +| File | ❌ | | +| Audio | ❌ | | +| Video | ❌ | | +| Location | ❌ | | From 94275f01f4341e634280ffa83ef24e2e6400df81 Mon Sep 17 00:00:00 2001 From: Varun Seth <44404326+ivarunseth@users.noreply.github.com> Date: Wed, 19 Jun 2024 12:13:46 +0530 Subject: [PATCH 18/33] Update README.md --- packages/channels/src/whatsapp/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/channels/src/whatsapp/README.md b/packages/channels/src/whatsapp/README.md index f33a94a86..527101839 100644 --- a/packages/channels/src/whatsapp/README.md +++ b/packages/channels/src/whatsapp/README.md @@ -2,7 +2,7 @@ ### Sending -| Channels | Messenger | Details | +| Channels | Whatsapp | Details | | -------- | :-------: | :-------------------------------- | | Text | ✅ | | | Image | ✅ | | @@ -17,7 +17,7 @@ ### Receiving -| Channels | Messenger | Details | +| Channels | Whatsapp | Details | | ------------- | :-------: | :------ | | Text | ✅ | | | Quick Reply | ✅ | | From 843e6cab80414a92a5ee86669e54bc52930714e8 Mon Sep 17 00:00:00 2001 From: Varun Seth Date: Wed, 28 Aug 2024 02:00:50 +0530 Subject: [PATCH 19/33] added whatsapp interfaces for media and interactive messages --- packages/channels/src/whatsapp/whatsapp.ts | 110 +++++++++++++++++---- 1 file changed, 89 insertions(+), 21 deletions(-) diff --git a/packages/channels/src/whatsapp/whatsapp.ts b/packages/channels/src/whatsapp/whatsapp.ts index 1763928dc..3ede456fb 100644 --- a/packages/channels/src/whatsapp/whatsapp.ts +++ b/packages/channels/src/whatsapp/whatsapp.ts @@ -10,6 +10,13 @@ export interface WhatsappPhoneNumberInfo { id: string } +export interface WhatsappContact { + profile: { + name: string + } + wa_id: string +} + export interface WhatsappPayload { object: string entry: WhatsappEntry[] @@ -20,27 +27,7 @@ export interface WhatsappEntry { changes: WhatsappChange[] } -export interface WhatsappChange { - value: { - messaging_product: string - metadata: { - display_phone_number: string - phone_number_id: string - } - contacts: WhatsappContact[] - messages?: WhatsappMessage[] - } - field: string -} - -export interface WhatsappContact { - profile: { - name: string - } - wa_id: string -} - -export interface WhatsappMessage { +export interface WhatsappIncomingMessage { from: string id: string timestamp: string @@ -61,3 +48,84 @@ export interface WhatsappMessage { } } } + +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 +} From 25df1398d4dc91af22d4dc29cffebe7f70f5731c Mon Sep 17 00:00:00 2001 From: Varun Seth Date: Wed, 28 Aug 2024 02:03:29 +0530 Subject: [PATCH 20/33] using whatsapp message interface for type safety --- packages/channels/src/whatsapp/context.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/channels/src/whatsapp/context.ts b/packages/channels/src/whatsapp/context.ts index 382ae215a..5fe39965e 100644 --- a/packages/channels/src/whatsapp/context.ts +++ b/packages/channels/src/whatsapp/context.ts @@ -1,9 +1,9 @@ -import { ChannelContext, IndexChoiceOption} from '../base/context' +import { ChannelContext } from '../base/context' import { WhatsappState } from './service' import { WhatsappStream } from './stream' +import { WhatsappOutgoingMessage } from './whatsapp' export type WhatsappContext = ChannelContext & { - messages: any[] + messages: WhatsappOutgoingMessage[] stream: WhatsappStream - prepareIndexResponse(scope: string, identity: string, sender: string, options: IndexChoiceOption[]): void } From 34f57f679ce6de34d92b49b88f50783392e72268 Mon Sep 17 00:00:00 2001 From: Varun Seth Date: Wed, 28 Aug 2024 02:05:20 +0530 Subject: [PATCH 21/33] updated function to handle incoming messages to bot. Also added support for say_something, postback & open_url actions for carousel content. --- packages/channels/src/whatsapp/api.ts | 39 +++++++++++++-------------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/packages/channels/src/whatsapp/api.ts b/packages/channels/src/whatsapp/api.ts index 79371c672..c2c0a2de0 100644 --- a/packages/channels/src/whatsapp/api.ts +++ b/packages/channels/src/whatsapp/api.ts @@ -3,7 +3,7 @@ import express, { Response, Request, NextFunction } from 'express' import { IncomingMessage } from 'http' import { ChannelApi, ChannelApiManager, ChannelApiRequest } from '../base/api' import { WhatsappService } from './service' -import { WhatsappMessage, WhatsappPayload } from './whatsapp' +import { WhatsappIncomingMessage, WhatsappPayload } from './whatsapp' export class WhatsappApi extends ChannelApi { async setup(router: ChannelApiManager) { @@ -65,34 +65,31 @@ export class WhatsappApi extends ChannelApi { res.status(200).send('EVENT_RECEIVED') } - private async receive(scope: string, message: WhatsappMessage) { + private async receive(scope: string, message: WhatsappIncomingMessage) { if (message && message.id && message.type && message.from) { + let content: any if (message.type === 'text' && message.text && message.text.body) { - await this.service.receive(scope, this.extractEndpoint(message), { - id: message.id, - type: 'text', - text: message.text.body - }) + content = {type: 'text', text: message.text.body} } else if (message.type === 'interactive' && message.interactive) { - let payload - if (message.interactive.type === 'button_reply' && message.interactive.button_reply) { - payload = message.interactive.button_reply - } else if (message.interactive.type === 'list_reply' &&message.interactive.list_reply) { - payload = message.interactive.list_reply - } - if (payload?.id.startsWith('reply::')) { - await this.service.receive(scope, this.extractEndpoint(message), { - id: message.id, - type: 'quick_reply', - text: payload.title, - payload: payload.id.replace('reply::', '') - }) + const reply = message.interactive.button_reply || message.interactive.list_reply + if (reply) { + const [type, payload] = reply.id.split('::') + if (type === 'postback') { + content = {type, payload} + } else if (type === 'say_something') { + content = {type, text: payload} + } else if (type === 'quick_reply') { + content = {type, text: reply.title, payload} + } else if (type === 'open_url') { + content = {type: 'say_something', text: payload} + } } } + await this.service.receive(scope, this.extractEndpoint(message), content) } } - private extractEndpoint(message: WhatsappMessage) { + private extractEndpoint(message: WhatsappIncomingMessage) { return { identity: '*', sender: message.from, From 0976daabaacec2d8d6e45c21d0cbcba9ed2edd91 Mon Sep 17 00:00:00 2001 From: Varun Seth Date: Wed, 28 Aug 2024 02:06:34 +0530 Subject: [PATCH 22/33] Updated graph api version to v20.0 & added try catch block for request call. --- packages/channels/src/whatsapp/stream.ts | 35 +++++++++++++----------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/channels/src/whatsapp/stream.ts b/packages/channels/src/whatsapp/stream.ts index 6f9715468..056502083 100644 --- a/packages/channels/src/whatsapp/stream.ts +++ b/packages/channels/src/whatsapp/stream.ts @@ -11,7 +11,7 @@ import { WhatsappSenders } from './senders' import { WhatsappService } from './service' import { WhatsappPhoneNumberInfo } from './whatsapp' -const GRAPH_URL = 'https://graph.facebook.com/v18.0' +const GRAPH_URL = 'https://graph.facebook.com/v20.0' export class WhatsappStream extends ChannelStream { get renderers() { @@ -69,20 +69,24 @@ export class WhatsappStream extends ChannelStream { @@ -106,8 +110,7 @@ export class WhatsappStream extends ChannelStream Date: Wed, 28 Aug 2024 02:07:55 +0530 Subject: [PATCH 23/33] setting preview_url to true in case outgoing text message has a url, whatsapp will preview the webpage. --- packages/channels/src/whatsapp/renderers/text.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/channels/src/whatsapp/renderers/text.ts b/packages/channels/src/whatsapp/renderers/text.ts index 319562f06..c806d580f 100644 --- a/packages/channels/src/whatsapp/renderers/text.ts +++ b/packages/channels/src/whatsapp/renderers/text.ts @@ -7,7 +7,7 @@ export class WhatsappTextRenderer extends TextRenderer { context.messages.push({ type: 'text', text: { - preview_url: false, + preview_url: true, body: payload.text } }) From 2965cec210c9ce9d451f5680a11f44bfd2f0ee2f Mon Sep 17 00:00:00 2001 From: Varun Seth Date: Wed, 28 Aug 2024 02:14:47 +0530 Subject: [PATCH 24/33] Added support for more than 10 choices by formatting them as text similar to Twilio & Vonage channels. Handled constraints for content length of button title, body text etc. --- .../src/whatsapp/renderers/choices.ts | 85 +++++++++++-------- 1 file changed, 51 insertions(+), 34 deletions(-) diff --git a/packages/channels/src/whatsapp/renderers/choices.ts b/packages/channels/src/whatsapp/renderers/choices.ts index 5c326f685..b6c5f1f8c 100644 --- a/packages/channels/src/whatsapp/renderers/choices.ts +++ b/packages/channels/src/whatsapp/renderers/choices.ts @@ -1,49 +1,66 @@ import { ChoicesRenderer } from '../../base/renderers/choices' import { ChoiceContent } from '../../content/types' import { WhatsappContext } from '../context' +import { WhatsappOutgoingMessage } from '../whatsapp' export class WhatsappChoicesRenderer extends ChoicesRenderer { renderChoice(context: WhatsappContext, payload: ChoiceContent): void { - if (payload.choices.length <= 3) { - context.messages[0] = { - type: 'interactive', - interactive: { - type: 'button', - body: { - text: payload.text - }, - action: { - buttons: payload.choices.map((choice) => ({ - type: 'reply', - reply: { - id: `reply::${choice.value}`, - title: choice.title + if (payload.choices.length) { + let message: WhatsappOutgoingMessage + if (payload.choices.length <= 10) { + if (payload.choices.length <= 3) { + message = { + type: 'interactive', + interactive: { + type: 'button', + body: { + text: payload.text.substring(0, 1024) + }, + action: { + buttons: payload.choices.map((choice) => ({ + type: 'reply', + reply: { + id: `quick_reply::${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 - }, - action: { - button: 'Browse', - sections: [ - { - rows: payload.choices.map((choice) => ({ - id: `reply::${choice.value}`, - title: choice.title - })) + } else { + message = { + type: 'interactive', + interactive: { + type: 'list', + body: { + text: payload.text.substring(0, 1024) + }, + action: { + button: 'Select...', + sections: [ + { + rows: payload.choices.map((choice) => ({ + id: `quick_reply::${choice.value}`, + title: choice.title.substring(0, 20) + })) + } + ] } - ] + } + } + } + } else { + message = { + type: 'text', + text: { + preview_url: false, + body: `${payload.text}\n\n${payload.choices + .map(({ title }, index) => `*${index + 1}.)* ${title}`) + .join('\n')}` } } } + context.messages[0] = message } } } From 350323cb22224507c43e5f7646c02f512402dd86 Mon Sep 17 00:00:00 2001 From: Varun Seth Date: Wed, 28 Aug 2024 02:15:58 +0530 Subject: [PATCH 25/33] Added carousel renderer implemented using whatsapp interactive messages. --- .../src/whatsapp/renderers/carousel.ts | 146 ++++++++++++++++++ .../channels/src/whatsapp/renderers/index.ts | 4 +- 2 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 packages/channels/src/whatsapp/renderers/carousel.ts diff --git a/packages/channels/src/whatsapp/renderers/carousel.ts b/packages/channels/src/whatsapp/renderers/carousel.ts new file mode 100644 index 000000000..52460ff82 --- /dev/null +++ b/packages/channels/src/whatsapp/renderers/carousel.ts @@ -0,0 +1,146 @@ +import { CarouselRenderer, CarouselContext } from '../../base/renderers/carousel' +import { ActionOpenURL, ActionPostback, ActionSaySomething, CardContent, CarouselContent } from '../../content/types' +import { WhatsappContext } from '../context' +import { WhatsappButton, WhatsappOutgoingMessage } from '../whatsapp' + +type Context = CarouselContext & { + buttons: WhatsappButton[] +} + +export class WhatsappCarouselRenderer extends CarouselRenderer { + startRender(context: Context, carousel: CarouselContent) {} + + startRenderCard(context: Context, card: CardContent) { + context.buttons = [] + } + + renderButtonUrl(context: Context, button: ActionOpenURL) { + context.buttons.push({ + type: 'reply', + reply: { + id: `open_url::${button.url}`, + title: button.title.substring(0, 20) + } + }) + } + + renderButtonPostback(context: Context, button: ActionPostback) { + context.buttons.push({ + type: 'reply', + reply: { + id: `postback::${button.payload}`, + title: button.title.substring(0, 20) + } + }) + } + + renderButtonSay(context: Context, button: ActionSaySomething) { + context.buttons.push({ + type: 'reply', + reply: { + id: `say_something::${button.text}`, + title: button.title.substring(0, 20) + } + }) + } + + endRenderCard(context: Context, card: CardContent) { + let message: WhatsappOutgoingMessage + + if (!context.buttons.length) { + if (card.image) { + message = { + type: 'image', + image: { + link: card.image, + caption: card.subtitle ? `${card.title}\n\n${card.subtitle}` : card.title + } + } + } else { + message = { + type: 'text', + text: { + preview_url: true, + body: card.subtitle ? `${card.title}\n\n${card.subtitle}` : card.title + } + } + } + } else if (1 <= context.buttons.length && context.buttons.length <= 10) { + if (context.buttons.length <= 3) { + message = { + type: 'interactive', + interactive: { + type: 'button', + body: { + text: card.title.substring(0, 1024) + }, + action: { + buttons: context.buttons + } + } + } + } else { + message = { + type: 'interactive', + interactive: { + type: 'list', + body: { + text: card.title.substring(0, 1024) + }, + action: { + button: 'Select...', + sections: [ + { + rows: context.buttons.map((button) => ({ + id: button.reply.id, + title: button.reply.title + })) + } + ] + } + } + } + } + if (message.interactive) { + 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 { + const text = `*${card.title}*\n\n${card.subtitle ? `${card.subtitle}\n\n` : ''} + ${context.buttons.map(({ reply }, index) => `*${index + 1}.)* ${reply.title}`) + .join('\n')}` + + if (card.image) { + message = { + type: 'image', + image: { + link: card.image, + caption: text + } + } + } else { + message = { + type: 'text', + text: { + preview_url: false, + body: text + } + } + } + } + context.channel.messages.push(message) + } + + endRender(context: Context, carousel: CarouselContent) {} +} diff --git a/packages/channels/src/whatsapp/renderers/index.ts b/packages/channels/src/whatsapp/renderers/index.ts index 1e50a730a..3898bc66a 100644 --- a/packages/channels/src/whatsapp/renderers/index.ts +++ b/packages/channels/src/whatsapp/renderers/index.ts @@ -1,5 +1,5 @@ import { WhatsappAudioRenderer } from './audio' -// import { WhatsappCarouselRenderer } from './carousel' +import { WhatsappCarouselRenderer } from './carousel' import { WhatsappChoicesRenderer } from './choices' import { WhatsappFileRenderer } from './file' import { WhatsappImageRenderer } from './image' @@ -10,7 +10,7 @@ import { WhatsappVideoRenderer } from './video' export const WhatsappRenderers = [ new WhatsappTextRenderer(), new WhatsappImageRenderer(), - // new WhatsappCarouselRenderer(), + new WhatsappCarouselRenderer(), new WhatsappChoicesRenderer(), new WhatsappFileRenderer(), new WhatsappAudioRenderer(), From 8224ebe12838529a1128ca38e4804c0670ece27e Mon Sep 17 00:00:00 2001 From: Varun Seth Date: Wed, 28 Aug 2024 02:17:33 +0530 Subject: [PATCH 26/33] updated README.md file --- packages/channels/src/whatsapp/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/channels/src/whatsapp/README.md b/packages/channels/src/whatsapp/README.md index 527101839..c5fbe4a1a 100644 --- a/packages/channels/src/whatsapp/README.md +++ b/packages/channels/src/whatsapp/README.md @@ -9,7 +9,7 @@ | Choice | ✅ | | | Dropdown | ✅ | | | Card | ✅ | | -| Carousel | ❌ | | +| Carousel | ✅ | | | File | ✅ | File sent as URL | | Audio | ✅ | Audio sent as URL | | Video | ✅ | Video sent as URL | From 0282a8b6d58f65492317c7698f16e2269edc1403 Mon Sep 17 00:00:00 2001 From: Varun Seth Date: Sat, 21 Sep 2024 18:48:04 +0530 Subject: [PATCH 27/33] using whatsapp interactice reply buttons and list messages if possible else using fallback logic. --- .../src/whatsapp/renderers/carousel.ts | 136 ++++++++++-------- .../src/whatsapp/renderers/choices.ts | 96 +++++++------ 2 files changed, 130 insertions(+), 102 deletions(-) diff --git a/packages/channels/src/whatsapp/renderers/carousel.ts b/packages/channels/src/whatsapp/renderers/carousel.ts index 52460ff82..d212a3965 100644 --- a/packages/channels/src/whatsapp/renderers/carousel.ts +++ b/packages/channels/src/whatsapp/renderers/carousel.ts @@ -1,14 +1,20 @@ + +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, WhatsappOutgoingMessage } from '../whatsapp' +import { WhatsappButton } from '../whatsapp' type Context = CarouselContext & { buttons: WhatsappButton[] + options: IndexChoiceOption[] + index: number } export class WhatsappCarouselRenderer extends CarouselRenderer { - startRender(context: Context, carousel: CarouselContent) {} + startRender(context: Context, carousel: CarouselContent) { + context.options = [] + } startRenderCard(context: Context, card: CardContent) { context.buttons = [] @@ -18,7 +24,7 @@ export class WhatsappCarouselRenderer extends CarouselRenderer { context.buttons.push({ type: 'reply', reply: { - id: `open_url::${button.url}`, + id: `${IndexChoiceType.OpenUrl}::${button.url}`.substring(0, 256), title: button.title.substring(0, 20) } }) @@ -28,7 +34,7 @@ export class WhatsappCarouselRenderer extends CarouselRenderer { context.buttons.push({ type: 'reply', reply: { - id: `postback::${button.payload}`, + id: `${IndexChoiceType.PostBack}::${button.payload}`.substring(0, 256), title: button.title.substring(0, 20) } }) @@ -38,22 +44,22 @@ export class WhatsappCarouselRenderer extends CarouselRenderer { context.buttons.push({ type: 'reply', reply: { - id: `say_something::${button.text}`, + id: `${IndexChoiceType.SaySomething}::${button.text}`.substring(0, 256), title: button.title.substring(0, 20) } }) } endRenderCard(context: Context, card: CardContent) { - let message: WhatsappOutgoingMessage - + 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: card.subtitle ? `${card.title}\n\n${card.subtitle}` : card.title + caption: text.substring(0, 1024) } } } else { @@ -61,64 +67,65 @@ export class WhatsappCarouselRenderer extends CarouselRenderer { type: 'text', text: { preview_url: true, - body: card.subtitle ? `${card.title}\n\n${card.subtitle}` : card.title + body: text.substring(0, 4096) } } } - } else if (1 <= context.buttons.length && context.buttons.length <= 10) { - if (context.buttons.length <= 3) { - message = { - type: 'interactive', - interactive: { - type: 'button', - body: { - text: card.title.substring(0, 1024) - }, - action: { - buttons: context.buttons - } + } else if (context.buttons.length <= 3) { + message = { + type: 'interactive', + interactive: { + type: 'button', + body: { + text: card.title.substring(0, 1024) + }, + action: { + buttons: context.buttons } } - } else { - message = { - type: 'interactive', - interactive: { - type: 'list', - body: { - text: card.title.substring(0, 1024) - }, - action: { - button: 'Select...', - sections: [ - { - rows: context.buttons.map((button) => ({ - id: button.reply.id, - title: button.reply.title - })) - } - ] - } + } + if (card.image) { + message.interactive.header = { + type: 'image', + image: { + link: card.image } } } - if (message.interactive) { - if (card.image) { - message.interactive.header = { - type: 'image', - image: { - link: card.image - } - } + if (card.subtitle) { + message.interactive.footer = { + text: card.subtitle.substring(0, 60) } - 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 ? `${card.subtitle}\n\n` : ''} - ${context.buttons.map(({ reply }, index) => `*${index + 1}.)* ${reply.title}`) + 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) { @@ -126,7 +133,7 @@ export class WhatsappCarouselRenderer extends CarouselRenderer { type: 'image', image: { link: card.image, - caption: text + caption: text.substring(0, 1024) } } } else { @@ -134,13 +141,30 @@ export class WhatsappCarouselRenderer extends CarouselRenderer { type: 'text', text: { preview_url: false, - body: text + 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) {} + 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 index b6c5f1f8c..dcf66cc9f 100644 --- a/packages/channels/src/whatsapp/renderers/choices.ts +++ b/packages/channels/src/whatsapp/renderers/choices.ts @@ -1,66 +1,70 @@ +import { IndexChoiceType } from '../../base/context' import { ChoicesRenderer } from '../../base/renderers/choices' -import { ChoiceContent } from '../../content/types' +import { ChoiceContent, ChoiceOption } from '../../content/types' import { WhatsappContext } from '../context' -import { WhatsappOutgoingMessage } from '../whatsapp' export class WhatsappChoicesRenderer extends ChoicesRenderer { renderChoice(context: WhatsappContext, payload: ChoiceContent): void { - if (payload.choices.length) { - let message: WhatsappOutgoingMessage - if (payload.choices.length <= 10) { - if (payload.choices.length <= 3) { - message = { - type: 'interactive', - interactive: { - type: 'button', - body: { - text: payload.text.substring(0, 1024) - }, - action: { - buttons: payload.choices.map((choice) => ({ - type: 'reply', - reply: { - id: `quick_reply::${choice.value}`, - title: choice.title.substring(0, 20) - } - })) - } + 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 { - message = { - type: 'interactive', - interactive: { - type: 'list', - body: { - text: payload.text.substring(0, 1024) - }, - action: { - button: 'Select...', - sections: [ - { - rows: payload.choices.map((choice) => ({ - id: `quick_reply::${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 { - message = { + 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: `${payload.text}\n\n${payload.choices - .map(({ title }, index) => `*${index + 1}.)* ${title}`) - .join('\n')}` + body: text.substring(0, 4096) } } + context.prepareIndexResponse( + context.scope, + context.identity, + context.sender, + payload.choices.map((choice: ChoiceOption) => ({...choice, type: IndexChoiceType.QuickReply })) + ) } - context.messages[0] = message } } } From adafa05fe05b8088153c1c9d1b6f3c3807dabc93 Mon Sep 17 00:00:00 2001 From: Varun Seth Date: Sat, 21 Sep 2024 18:49:19 +0530 Subject: [PATCH 28/33] truncating length of text strings according to whatsapp cloud api post request parameters. --- packages/channels/src/whatsapp/renderers/file.ts | 2 +- packages/channels/src/whatsapp/renderers/image.ts | 2 +- packages/channels/src/whatsapp/renderers/text.ts | 2 +- packages/channels/src/whatsapp/renderers/video.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/channels/src/whatsapp/renderers/file.ts b/packages/channels/src/whatsapp/renderers/file.ts index b62ec880c..eb1c720ad 100644 --- a/packages/channels/src/whatsapp/renderers/file.ts +++ b/packages/channels/src/whatsapp/renderers/file.ts @@ -8,7 +8,7 @@ export class WhatsappFileRenderer extends FileRenderer { type: 'document', document: { link: payload.file, - caption: payload.title + 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 index ed3e6df72..076743cab 100644 --- a/packages/channels/src/whatsapp/renderers/image.ts +++ b/packages/channels/src/whatsapp/renderers/image.ts @@ -8,7 +8,7 @@ export class WhatsappImageRenderer extends ImageRenderer { type: 'image', image: { link: payload.image, - caption: payload.title + caption: payload.title ? payload.title.substring(0, 1024) : '' } }) } diff --git a/packages/channels/src/whatsapp/renderers/text.ts b/packages/channels/src/whatsapp/renderers/text.ts index c806d580f..a9fc3eb35 100644 --- a/packages/channels/src/whatsapp/renderers/text.ts +++ b/packages/channels/src/whatsapp/renderers/text.ts @@ -8,7 +8,7 @@ export class WhatsappTextRenderer extends TextRenderer { type: 'text', text: { preview_url: true, - body: payload.text + body: payload.text.substring(0, 4096) } }) } diff --git a/packages/channels/src/whatsapp/renderers/video.ts b/packages/channels/src/whatsapp/renderers/video.ts index a867172de..2ed290db8 100644 --- a/packages/channels/src/whatsapp/renderers/video.ts +++ b/packages/channels/src/whatsapp/renderers/video.ts @@ -8,7 +8,7 @@ export class WhatsappVideoRenderer extends VideoRenderer { type: 'video', video: { link: payload.video, - caption: payload.title + caption: payload.title ? payload.title.substring(0, 1024) : '' } }) } From fe2080d8209be4b9e43edf6f728f57d94a6f1ed9 Mon Sep 17 00:00:00 2001 From: Varun Seth Date: Sat, 21 Sep 2024 18:50:09 +0530 Subject: [PATCH 29/33] updated logic of receive function to handle index responses in the api. --- packages/channels/src/whatsapp/api.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/channels/src/whatsapp/api.ts b/packages/channels/src/whatsapp/api.ts index c2c0a2de0..0d3d44f1e 100644 --- a/packages/channels/src/whatsapp/api.ts +++ b/packages/channels/src/whatsapp/api.ts @@ -2,6 +2,7 @@ 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' @@ -67,25 +68,30 @@ export class WhatsappApi extends ChannelApi { 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) { - content = {type: 'text', 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 === 'postback') { + if (type === IndexChoiceType.PostBack) { content = {type, payload} - } else if (type === 'say_something') { + } else if (type === IndexChoiceType.SaySomething) { content = {type, text: payload} - } else if (type === 'quick_reply') { + } else if (type === IndexChoiceType.QuickReply) { content = {type, text: reply.title, payload} - } else if (type === 'open_url') { - content = {type: 'say_something', text: payload} + } else if (type === IndexChoiceType.OpenUrl) { + content = {type: IndexChoiceType.SaySomething, text: payload} } } } - await this.service.receive(scope, this.extractEndpoint(message), content) + await this.service.receive(scope, endpoint, content) } } From 73befc7399faf212030e8bccf3fc9e0a9ceb0998 Mon Sep 17 00:00:00 2001 From: Varun Seth Date: Sat, 21 Sep 2024 18:50:57 +0530 Subject: [PATCH 30/33] added function to prepare index responses in case fallback logic is used while rendering choice or carousel. --- packages/channels/src/whatsapp/context.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/channels/src/whatsapp/context.ts b/packages/channels/src/whatsapp/context.ts index 5fe39965e..dc5596190 100644 --- a/packages/channels/src/whatsapp/context.ts +++ b/packages/channels/src/whatsapp/context.ts @@ -1,4 +1,4 @@ -import { ChannelContext } from '../base/context' +import { ChannelContext, IndexChoiceOption } from '../base/context' import { WhatsappState } from './service' import { WhatsappStream } from './stream' import { WhatsappOutgoingMessage } from './whatsapp' @@ -6,4 +6,5 @@ import { WhatsappOutgoingMessage } from './whatsapp' export type WhatsappContext = ChannelContext & { messages: WhatsappOutgoingMessage[] stream: WhatsappStream + prepareIndexResponse(scope: string, identity: string, sender: string, options: IndexChoiceOption[]): void } From d90c24482793736d2eabd68f6320b06a8cb867d0 Mon Sep 17 00:00:00 2001 From: Varun Seth Date: Sat, 21 Sep 2024 18:51:29 +0530 Subject: [PATCH 31/33] binded the prepareIndexResponse function to stream. --- packages/channels/src/whatsapp/stream.ts | 33 +++++++++++------------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/packages/channels/src/whatsapp/stream.ts b/packages/channels/src/whatsapp/stream.ts index 056502083..004c87560 100644 --- a/packages/channels/src/whatsapp/stream.ts +++ b/packages/channels/src/whatsapp/stream.ts @@ -69,24 +69,20 @@ export class WhatsappStream extends ChannelStream { @@ -109,8 +105,9 @@ export class WhatsappStream extends ChannelStream): Promise { return { ...base, + stream: this, messages: [], - stream: this + prepareIndexResponse: this.service.prepareIndexResponse.bind(this.service) } } } From e644410224715a6ba7f008a9f5bf87230b1bc3f5 Mon Sep 17 00:00:00 2001 From: Varun Seth <44404326+ivarunseth@users.noreply.github.com> Date: Wed, 2 Oct 2024 18:10:39 +0530 Subject: [PATCH 32/33] Create whatsapp.md --- docs/channels/v1/whatsapp.md | 89 ++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 docs/channels/v1/whatsapp.md 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. From 02315f6d21d59ee8096902883d4a6c26940f2efc Mon Sep 17 00:00:00 2001 From: Varun Seth <44404326+ivarunseth@users.noreply.github.com> Date: Wed, 2 Oct 2024 18:12:02 +0530 Subject: [PATCH 33/33] Update readme.md --- docs/channels/v1/readme.md | 1 + 1 file changed, 1 insertion(+) 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