Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions integrations/whatsapp/hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,33 @@

The WhatsApp integration allows your AI-powered chatbot to seamlessly connect with WhatsApp, one of the most popular messaging platforms worldwide. Integrate your chatbot with WhatsApp to engage with your audience, automate conversations, and provide instant support. With this integration, you can send messages, handle inquiries, deliver notifications, and perform actions directly within WhatsApp. Leverage WhatsApp's powerful features such as text messages, media sharing, document sharing, and more to create personalized and interactive chatbot experiences. Connect with users on a platform they already use and enhance customer engagement with the WhatsApp Integration for Botpress.

## Card and carousel rendering

WhatsApp has no direct equivalent of Botpress's `card` and `carousel` types. The integration maps each to a native WhatsApp message type:

- `postback` / `say` actions → [Reply Buttons](https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/interactive-reply-buttons-messages) (up to 3 per bubble)
- `url` actions → [Interactive CTA URL](https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/interactive-cta-url-messages) (one URL button per bubble)
- A group of cards meeting the carousel rules → Interactive Media Carousel

### Cards

A card renders as one or more bubbles in original action order:

- The first bubble carries the image as a header and the title+subtitle together as the body; later bubbles are minimal.
- More than 3 postback/say buttons are split across multiple bubbles (3 per bubble).
- Multiple URL actions each become their own CTA bubble.

### Carousel

A `carousel` becomes one or more native Carousels (up to 10 cards each) when every card:

- has an `imageUrl`,
- has exactly one `url` action OR 1-2 `postback`/`say` actions (no mixing),
- has a combined title + subtitle body of 160 characters or fewer, and
- has quick-reply values unique across the carousel.

Cards with the same shape are grouped together (up to 10 per group). If any condition fails, or if grouping would leave a single card on its own, the whole carousel renders per-card instead and a warning is logged with the reason.

## Migrating from 3.x to 4.x

### Automatic downloading of media files
Expand Down
2 changes: 1 addition & 1 deletion integrations/whatsapp/integration.definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ const defaultBotPhoneNumberId = {
}

export const INTEGRATION_NAME = 'whatsapp'
export const INTEGRATION_VERSION = '4.14.0'
export const INTEGRATION_VERSION = '4.15.0'
export default new IntegrationDefinition({
name: INTEGRATION_NAME,
version: INTEGRATION_VERSION,
Expand Down
6 changes: 4 additions & 2 deletions integrations/whatsapp/src/channels/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import * as carousel from './message-types/carousel'
import * as choice from './message-types/choice'
import * as dropdown from './message-types/dropdown'
import * as image from './message-types/image'
import { RawInteractiveMessage } from './message-types/raw-interactive'
import * as bp from '.botpress'

const PART_DELAY_MS = 1000
Expand Down Expand Up @@ -114,8 +115,8 @@ export const channel: bp.IntegrationProps['channels']['channel'] = {
message: new Location(payload.longitude, payload.latitude),
})
},
carousel: async ({ payload, logger, ...props }) => {
await _sendMany({ ...props, logger, generator: carousel.generateOutgoingMessages(payload, logger) })
carousel: async (props) => {
await _sendMany({ ...props, generator: carousel.generateOutgoingMessages(props) })
},
card: async ({ payload, logger, ...props }) => {
await _sendMany({ ...props, logger, generator: card.generateOutgoingMessages(payload, logger) })
Expand Down Expand Up @@ -229,6 +230,7 @@ type OutgoingMessage =
| Interactive
| Template
| Reaction
| RawInteractiveMessage

type SendMessageProps = {
client: bp.Client
Expand Down
205 changes: 137 additions & 68 deletions integrations/whatsapp/src/channels/message-types/card.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,30 @@
import { Text, Interactive, ActionButtons, Header, Image, Button, ActionCTA } from 'whatsapp-api-js/messages'
import { Text, Interactive, ActionButtons, ActionCTA, Header, Image, Body, Button } from 'whatsapp-api-js/messages'
import { WHATSAPP } from '../../misc/constants'
import { convertMarkdownToWhatsApp } from '../../misc/markdown-to-whatsapp-rtf'
import { chunkArray, hasAtleastOne } from '../../misc/util'
import * as body from './interactive/body'
import * as button from './interactive/button'
import * as footer from './interactive/footer'
import { RawInteractiveMessage } from './raw-interactive'
import { channels } from '.botpress'
import * as bp from '.botpress'

// Standalone Interactive body cap (CTA URL & Reply Buttons share it)
const BODY_MAX_LENGTH = 1024
const BUTTON_ID_MAX_LENGTH = 256

const ZERO_WIDTH_SPACE = '​'

type Card = channels.channel.card.Card

export const formatCardBodyText = (card: Card): string | undefined => {
const title = card.title?.trim()
const subtitle = card.subtitle?.trim()
if (!title && !subtitle) return undefined
const formatted = title && subtitle ? `**${title}**\n\n${subtitle}` : title ? `**${title}**` : subtitle!
return convertMarkdownToWhatsApp(formatted)
}

const _truncatedBody = (text: string) => new Body(text.substring(0, BODY_MAX_LENGTH))

type SDKAction = Card['actions'][number]
type ActionURL = SDKAction & { action: 'url' }
type ActionSay = SDKAction & { action: 'say' }
Expand All @@ -23,49 +38,48 @@ export function* generateOutgoingMessages(card: Card, logger: bp.Logger) {
const actions = card.actions

if (actions.length === 0) {
// No actions, so we can't display an interactive message
for (const m of _generateHeader(card)) {
for (const m of _renderActionlessCard(card)) {
yield m
}
return
}

// We have to split the actions into two groups (URL actions and other actions) because buttons are sent differently than URLs
const urlActions = actions.filter(_isActionURL)
const nonUrlActions = actions.filter(_isNotActionUrl)

if (urlActions.length === 0) {
// All actions are either postback or say
for (const m of _generateButtonInteractiveMessages(card, nonUrlActions, logger)) {
yield m
let isFirstMessage = true
for (const run of _partitionActionsByKind(actions)) {
const opts = { attachContextToFirst: isFirstMessage }
const messages =
run.kind === 'url'
? _generateCTAUrlInteractiveMessages(card, run.actions, logger, opts)
: _generateButtonInteractiveMessages(card, run.actions, logger, opts)
for (const message of messages) {
yield message
}
return
isFirstMessage = false
}
}

if (nonUrlActions.length === 0) {
// All actions are URL
if (card.imageUrl) {
yield new Image(card.imageUrl)
}
type ActionRun = { kind: 'url'; actions: ActionURL[] } | { kind: 'button'; actions: Array<ActionSay | ActionPostback> }

for (const m of _generateCTAUrlInteractiveMessages(card, urlActions)) {
yield m
// WA doesn't allow mixing url (CTA) and postbacks (Reply), so we group sequential
function _partitionActionsByKind(actions: Action[]): ActionRun[] {
const runs: ActionRun[] = []
for (const a of actions) {
if (a.action === 'url') {
const last = runs[runs.length - 1]
if (last && last.kind === 'url') last.actions.push(a)
else runs.push({ kind: 'url', actions: [a] })
} else {
const last = runs[runs.length - 1]
if (last && last.kind === 'button') last.actions.push(a)
else runs.push({ kind: 'button', actions: [a] })
}

return
}

// We have have a mix of URL, postback and say actions
for (const m of _generateButtonInteractiveMessages(card, nonUrlActions, logger)) {
yield m
}

for (const m of _generateCTAUrlInteractiveMessages(card, urlActions)) {
yield m
}
return runs
}

function* _generateHeader(card: Card) {
// No actions → no interactive bubble; emit the card's image+text as plain
// messages so the content still reaches the user.
function* _renderActionlessCard(card: Card) {
if (card.imageUrl) {
yield new Image(card.imageUrl, false, card.title)
} else {
Expand All @@ -77,43 +91,39 @@ function* _generateHeader(card: Card) {
}
}

function _isActionURL(action: Action): action is ActionURL {
return action.action === 'url'
}

function _isNotActionUrl(action: Action): action is ActionSay | ActionPostback {
return !_isActionURL(action)
}

function* _generateButtonInteractiveMessages(
card: Card,
actions: Array<ActionSay | ActionPostback>,
logger: bp.Logger
logger: bp.Logger,
opts: { attachContextToFirst: boolean }
) {
const [firstChunk, ...followingChunks] = chunkArray(actions, WHATSAPP.INTERACTIVE_MAX_BUTTONS_COUNT)
if (firstChunk) {
const actionButtons = _createActionButtons(firstChunk)
if (actionButtons) {
yield new Interactive(
actionButtons,
body.create(card.title),
card.imageUrl ? new Header(new Image(card.imageUrl, false)) : undefined,
card.subtitle ? footer.create(card.subtitle) : undefined
)
if (opts.attachContextToFirst) {
yield new Interactive(
actionButtons,
_truncatedBody(formatCardBodyText(card) ?? ZERO_WIDTH_SPACE),
card.imageUrl ? new Header(new Image(card.imageUrl, false)) : undefined
)
} else {
// Earlier message in this card already carried the image/title/subtitle —
// render this run as a button-only bubble so context doesn't repeat.
yield new Interactive(actionButtons, _truncatedBody(ZERO_WIDTH_SPACE))
}
} else {
logger.debug('No buttons in chunk, skipping first chunk')
}
}

if (followingChunks) {
for (const chunk of followingChunks) {
const actionsButtons = _createActionButtons(chunk)
if (!actionsButtons) {
logger.debug('No buttons in chunk, skipping')
continue
}
yield new Interactive(actionsButtons, body.create(card.title))
for (const chunk of followingChunks || []) {
const actionsButtons = _createActionButtons(chunk)
if (!actionsButtons) {
logger.debug('No buttons in chunk, skipping')
continue
}
yield new Interactive(actionsButtons, _truncatedBody(ZERO_WIDTH_SPACE))
}
}

Expand All @@ -133,25 +143,84 @@ function _createButtons(nonURLActions: Array<ActionSay | ActionPostback>) {
return buttons
}

function* _generateCTAUrlInteractiveMessages(card: Card, actions: ActionURL[]) {
let actionNumber = 1
function* _generateCTAUrlInteractiveMessages(
card: Card,
actions: ActionURL[],
_logger: bp.Logger,
opts: { attachContextToFirst: boolean }
) {
let isFirst = true

for (const action of actions) {
if (actionNumber === 1) {
// First CTA URL button will be in a WhatsApp card
const useFullContext = isFirst && opts.attachContextToFirst
if (useFullContext && card.imageUrl) {
yield buildCtaUrlMessage({
imageUrl: card.imageUrl,
bodyText: formatCardBodyText(card) ?? action.value,
displayText: action.label,
url: action.value,
})
} else if (useFullContext) {
yield new Interactive(
new ActionCTA(action.label, action.value),
body.create(card.subtitle ? convertMarkdownToWhatsApp(card.subtitle) : action.value),
card.title ? new Header(card.title) : undefined
_truncatedBody(formatCardBodyText(card) ?? action.value)
)
} else {
// Subsequent CTA URL buttons will be standalone
yield new Interactive(
new ActionCTA(action.label, action.value),
body.create('\u200B') // Zero width space character used to force the interactive message to be sent (WhatsApp documentation says body is optional but it's not actually true)
)
yield new Interactive(new ActionCTA(action.label, action.value), _truncatedBody(ZERO_WIDTH_SPACE))
}

actionNumber++
isFirst = false
}
}

export function buildCtaUrlPayload(opts: { imageUrl: string; bodyText?: string; displayText: string; url: string }) {
return {
type: 'cta_url' as const,
header: { type: 'image' as const, image: { link: opts.imageUrl } },
...(opts.bodyText !== undefined ? { body: { text: opts.bodyText } } : {}),
action: {
name: 'cta_url' as const,
parameters: {
display_text: opts.displayText.substring(0, WHATSAPP.BUTTON_LABEL_MAX_LENGTH),
url: opts.url,
},
},
}
}

export function buildQuickReplyPayload(opts: {
imageUrl: string
bodyText?: string
buttons: ReadonlyArray<{ id: string; title: string }>
}) {
return {
type: 'cta_url' as const,
header: { type: 'image' as const, image: { link: opts.imageUrl } },
...(opts.bodyText !== undefined ? { body: { text: opts.bodyText } } : {}),
action: {
buttons: opts.buttons.map((b) => ({
type: 'quick_reply' as const,
quick_reply: {
id: b.id.substring(0, BUTTON_ID_MAX_LENGTH),
title: b.title.substring(0, WHATSAPP.BUTTON_LABEL_MAX_LENGTH),
},
})),
},
}
}

function buildCtaUrlMessage(opts: {
imageUrl: string
bodyText: string
displayText: string
url: string
}): RawInteractiveMessage {
return new RawInteractiveMessage(
buildCtaUrlPayload({
imageUrl: opts.imageUrl,
bodyText: opts.bodyText.substring(0, BODY_MAX_LENGTH),
displayText: opts.displayText,
url: opts.url,
})
)
}
Loading
Loading