chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
@@ -0,0 +1,3 @@
{
"strictness": 1
}
+141
View File
@@ -0,0 +1,141 @@
import { z } from '@botpress/sdk'
export const qualityScoreSchema = z.enum(['GREEN', 'RED', 'YELLOW', 'UNKNOWN'])
export const WhatsAppMessageTemplateComponentsUpdateValueSchema = z
.object({
id: z.number().describe('Template ID.').title('Template Id'),
name: z.string().describe('Template name.').title('Template Name'),
language: z.string().describe('Template language and locale code.').title('Template Language'),
element: z.string().describe('Template body text.').title('Template Body Text'),
title: z.string().describe('Template Header Text.').title('Template Header Text').optional(),
footer: z.string().describe('Template Footer Text.').title('Template Footer Text').optional(),
buttons: z
.array(
z.object({
button_type: z
.enum([
'CATALOG',
'COPY_CODE',
'EXTENSION',
'FLOW',
'MPM',
'ORDER_DETAILS',
'OTP',
'PHONE_NUMBER',
'POSTBACK',
'REMINDER',
'SEND_LOCATION',
'SPM',
'QUICK_REPLY',
'URL',
'VOICE_CALL',
])
.describe('Button type.')
.title('Button Type'),
button_text: z.string().describe('Button label text.').title('Button Label Text'),
button_url: z.string().describe('Button URL.').title('Button URL').optional(),
button_phone_number: z.string().describe('Button phone number.').title('Button Phone Number').optional(),
})
)
.describe('Array of button objects, if present.')
.title('Buttons')
.optional(),
})
.describe("The message_template_components_update webhook notifies you of changes to a template's components.")
.title('Message Template Components Update')
.describe("The message_template_components_update webhook notifies you of changes to a template's components.")
export const WhatsAppMessageTemplateQualityUpdateValueSchema = z
.object({
previous_quality_score: qualityScoreSchema
.describe('Previous template quality score.')
.title('Previous Quality Score'),
new_quality_score: qualityScoreSchema.describe('New template quality score.').title('New Quality Score'),
id: z.number().describe('Template ID.').title('Template Id'),
name: z.string().describe('Template name.').title('Template Name'),
language: z.string().describe('Template language and locale code.').title('Template Language'),
})
.describe("The message_template_quality_update webhook notifies you of changes to a template's quality score.")
.title('Message Template Quality Update')
export const WhatsAppMessageTemplateStatusUpdateValueSchema = z
.object({
event: z
.enum([
'APPROVED',
'ARCHIVED',
'DELETED',
'DISABLED',
'FLAGGED',
'IN_APPEAL',
'LIMIT_EXCEEDED',
'LOCKED',
'PAUSED',
'PENDING',
'REINSTATED',
'PENDING_DELETION',
'REJECTED',
])
.describe('Template status event.')
.title('Template Status Event'),
id: z.number().describe('Template ID.').title('Template Id'),
name: z.string().describe('Template name.').title('Template Name'),
language: z.string().describe('Language and locale code.').title('Template Language'),
reason: z
.enum([
'ABUSIVE_CONTENT',
'CATEGORY_NOT_AVAILABLE',
'INCORRECT_CATEGORY',
'INVALID_FORMAT',
'NONE',
'PROMOTIONAL',
'SCAM',
'TAG_CONTENT_MISMATCH',
])
.describe('Template rejection reason, if rejected.')
.title('Rejection Reason')
.nullable(),
disable_info: z
.object({
disable_date: z
.number()
.describe('Unix timestamp indicating when the template was disabled.')
.title('Disable Timestamp'),
})
.describe('only included if template disabled')
.title('Disable Info')
.optional(),
other_info: z
.object({
title: z
.enum(['FIRST_PAUSE', 'SECOND_PAUSE', 'RATE_LIMITING_PAUSE', 'UNPAUSE', 'DISABLED'])
.describe('Title of template pause or unpause event.')
.title('Title'),
description: z
.string()
.describe('String describing why the template was locked or unlocked.')
.title('Description'),
})
.describe('only included if template locked or unlocked')
.title('Other Info')
.optional(),
})
.describe('The message_template_status_update webhook notifies you of changes to the status of an existing template.')
.title('Message Template Status Update')
export const WhatsAppTemplateCategoryUpdateValueSchema = z
.object({
id: z.number().describe('Template ID.').title('Template Id'),
name: z.string().describe('Template name.').title('Template Name'),
language: z.string().describe('Template language and locale code.').title('Template Language'),
correct_category: z
.string()
.describe('The category that the template will be recategorized as in 24 hours.')
.title('Correct Category')
.optional(),
previous_category: z.string().describe("The template's previous category.").title('Previous Category').optional(),
new_category: z.string().describe("The template's new category.").title('New Category').optional(),
})
.describe("The template_category_update webhook notifies you of changes to template's category.")
.title('Template Category Update')
+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+6
View File
@@ -0,0 +1,6 @@
metaId = null
body = parse_json!(.body)
if body.object == "whatsapp_business_account" {
metaId = body.entry[0].id
}
metaId
+40
View File
@@ -0,0 +1,40 @@
secrets = .secrets
q = parse_query_string!(.query)
mode = q."hub.mode"
challenge = q."hub.challenge"
verifyTokenReceived = q."hub.verify_token"
isSandbox = starts_with!(.path, "/sandbox")
verifyToken = if isSandbox {
secrets.SANDBOX_VERIFY_TOKEN
} else {
secrets.VERIFY_TOKEN
}
if mode == "subscribe" {
if verifyTokenReceived == verifyToken {
{
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": challenge
}
} else {
{
"status": 403,
"body": "Invalid verify token"
}
}
} else {
if isSandbox {
{
"status": 200, # Make sure unboud message requests are not retried
"body": "Conversation not linked to a bot"
}
} else {
{
"status": 403,
"body": "Invalid webhook request"
}
}
}
+65
View File
@@ -0,0 +1,65 @@
<iframe src="https://www.youtube.com/embed/Fs6dIxgEKoY" ></iframe>
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
Previously, accessing the content of media messages (such as images, videos, audio and documents) required authenticating with the WhatsApp API using a valid token. In version 4.0 of WhatsApp, the _Download Media_ parameter enables automatic downloading of media files. These downloaded files do not require authentication for access. However, they do count against your workspace's file storage. To continue using the WhatsApp API URLs, set the _Download Media_ parameter to disabled. The _Downloaded Media Expiry_ parameter allows you to set an expiry time for downloaded files.
### Interactive messages values
In version 4.0 of WhatsApp, all incoming button and list reply messages will include both the text displayed to the user (_text_) and the payload (_value_). Use `event.payload.text` to retrieve the label of a button or choice, and use `event.payload.value` to access the underlying value.
### _postback_ and _say_ messages prefix
In version 4.0 of WhatsApp, _postback_ and _say_ messages no longer use the prefixes `p:` or `s:`. If your bot relied on these prefixes for logic or transitions, you can update it to depend solely on the value set for the postback.
### Start conversation
Version 4.0 of WhatsApp introduces small changes in the call signature of the `startConversation` action:
- The `senderPhoneNumberId` parameter has been renamed to `botPhoneNumberId`
- The input object now includes a single property called `conversation`, which contains the actual arguments
If your bot used the `startConversation` action, make sure all parameters are set. Also, if you called `startConversation` from code, make sure the action is called with the correct arguments:
```ts
actions.whatsapp.startConversation({
conversation: {
userPhone: '+1 123 456 7890',
templateName: 'test_message',
templateLanguage: 'en',
templateVariablesJson: JSON.stringify(['First value', 'Second value'])
botPhoneNumberId: '1234567890'
}
})
```
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" clip-rule="evenodd"
stroke-linejoin="round" stroke-miterlimit="2" viewBox="0 -0.01 1172.16 1181.1">
<path
d="M308.678 1021.49l19.153 9.576a499.739 499.739 0 0 0 258.244 70.227c279.729-.638 509.563-231.016 509.563-510.744 0-135.187-53.692-265.012-149.169-360.713-95.35-96.69-225.62-151.18-361.383-151.18-278.451 0-507.552 229.133-507.552 507.552 0 2.203 0 4.373.032 6.576a523.81 523.81 0 0 0 76.612 268.14l12.768 19.153-51.074 188.337 192.806-46.925z"
fill="#00E676" fill-rule="nonzero" />
<path
d="M1003.29 172.378C894.597 61.482 745.49-.732 590.225 0h-.99C269.479.001 6.35 263.131 6.35 582.888c0 1.5.032 2.969.032 4.47a616.759 616.759 0 0 0 76.612 290.485L-.003 1181.097l309.32-79.804a569.202 569.202 0 0 0 278.993 70.228c320.939-1.756 584.036-266.385 583.844-587.356.766-154.213-60.044-302.52-168.864-411.787m-413.065 900.186a473.935 473.935 0 0 1-245.476-67.035l-19.153-9.577-184.187 47.883 47.882-181.953-12.768-19.153a484.242 484.242 0 0 1-72.558-254.957c0-265.65 218.599-484.25 484.25-484.25 265.65 0 484.248 218.6 484.248 484.25 0 167.269-86.666 323.11-228.781 411.372a464.838 464.838 0 0 1-251.86 73.42m280.59-354.329l-35.114-15.96s-51.075-22.346-82.996-38.306c-3.192 0-6.384-3.192-9.577-3.192a46.308 46.308 0 0 0-22.345 6.384c-6.799 3.99-3.192 3.192-47.882 54.266-3.032 5.97-9.257 9.705-15.96 9.577h-3.193a24.328 24.328 0 0 1-12.768-6.384l-15.961-6.385a309.91 309.91 0 0 1-92.573-60.65c-6.384-6.385-15.96-12.77-22.345-19.154a357.13 357.13 0 0 1-60.65-76.611l-3.193-6.384a46.475 46.475 0 0 1-6.384-12.769 23.915 23.915 0 0 1 3.192-15.96c2.905-4.789 12.769-15.962 22.345-25.538 9.577-9.576 9.577-15.96 15.961-22.345a39.33 39.33 0 0 0 6.384-31.922 1246.398 1246.398 0 0 0-51.074-121.301 37.099 37.099 0 0 0-22.345-15.961H380.82c-6.384 0-12.768 3.192-19.153 3.192l-3.192 3.192c-6.384 3.192-12.768 9.577-19.153 12.769-6.384 3.192-9.576 12.769-15.96 19.153a162.752 162.752 0 0 0-35.114 98.956 189.029 189.029 0 0 0 15.96 73.42l3.193 9.576a532.111 532.111 0 0 0 118.11 162.8l12.768 12.769a193.037 193.037 0 0 1 25.537 25.537c66.141 57.554 144.7 99.052 229.516 121.302 9.576 3.192 22.345 3.192 31.921 6.384h31.922a118.126 118.126 0 0 0 47.882-12.769c7.82-3.543 15.29-7.82 22.345-12.768l6.384-6.385c6.385-6.384 12.769-9.576 19.153-15.96a84.393 84.393 0 0 0 15.961-19.153c6.129-14.301 10.438-29.304 12.769-44.69V724.62a40.107 40.107 0 0 0-9.577-6.385"
fill="#fff" fill-rule="nonzero" />
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -0,0 +1,675 @@
import { posthogHelper } from '@botpress/common'
import { z, IntegrationDefinition, messages } from '@botpress/sdk'
import proactiveConversation from 'bp_modules/proactive-conversation'
import typingIndicator from 'bp_modules/typing-indicator'
import {
WhatsAppMessageTemplateComponentsUpdateValueSchema,
WhatsAppMessageTemplateQualityUpdateValueSchema,
WhatsAppMessageTemplateStatusUpdateValueSchema,
WhatsAppTemplateCategoryUpdateValueSchema,
} from './definitions/events'
// TODO: use default options
const toJSONSchemaOptions: Partial<z.transforms.JSONSchemaGenerationOptions> = {
discriminatedUnionStrategy: 'anyOf',
discriminator: false,
}
const MAX_BUTTON_LABEL_LENGTH = 20
const commonConfigSchema = z.object({
messageReadBehavior: z
.enum(['mark_as_read', 'typing_indicator', 'none'])
.default('typing_indicator')
.title('Message Read Behavior')
.describe(
'Behavior to adopt when a message is received from WhatsApp. "mark_as_read" will mark the message as read immediately, "typing_indicator" will show a typing indicator for a few seconds after marking the message as read, and "none" will do neither and block the typing indicator\'s emoji (leaving the message unread until a reply is sent).'
),
// TODO: in the next major version unify this with messageReadBehavior
typingIndicatorEmoji: z
.boolean()
.default(false)
.title('Typing Indicator Emoji')
.describe('Temporarily add an emoji to received messages to indicate when bot is processing message'),
downloadMedia: z
.boolean()
.default(true)
.title('Download Media')
.describe(
'Automatically download media files using the Files API for content access. If disabled, temporary WhatsApp media URLs will be used, which require authentication with a valid access token.'
),
downloadedMediaExpiry: z
.number()
.default(30 * 24) // 30 days
.optional()
.title('Downloaded Media Expiry')
.describe(
'Expiry time in hours for downloaded media files. An expiry time of 0 means the files will never expire.'
),
listenMessageEchoes: z
.boolean()
.default(false)
.optional()
.title('Listen Message Echoes')
.describe(
'When enabled, triggers an onMessageEchoReceived event when an external message is echoed via the webhook.'
),
})
const dropdownButtonLabelSchema = z
.string()
.max(MAX_BUTTON_LABEL_LENGTH)
.optional()
.title('Button Label')
.describe('Label for the dropdown button')
const startConversationProps = {
title: 'Start Conversation',
description:
'Proactively starts a conversation with a WhatsApp user by sending them a message using a WhatsApp Message Template',
input: {
schema: z.object({
conversation: z
.object({
userPhone: z
.string()
.min(1)
.title('User Phone Number')
.describe(
'Phone number of the WhatsApp user to start a conversation with. Add the country code (e.g. +81 for japan)'
),
templateName: z
.string()
.min(1)
.title('Message Template name')
.describe('Name of the WhatsApp Message Template to start the conversation with'),
templateLanguage: z
.string()
.optional()
.title('Message Template language')
.describe(
'Language of the WhatsApp Message Template to start the conversation with. Defaults to "en" (English)'
),
templateVariablesJson: z
.string()
.optional()
.title('[DEPRECATED] Message Template variables')
.describe(
'Deprecated: use templateBodyParams instead. JSON array of body variable values: ["val1", "val2"].'
),
templateHeaderParams: z
.discriminatedUnion('type', [
z.object({ type: z.literal('text'), value: z.string(), parameterName: z.string().optional() }),
z.object({ type: z.literal('image'), url: z.string() }),
z.object({ type: z.literal('video'), url: z.string() }),
z.object({ type: z.literal('document'), url: z.string(), filename: z.string().optional() }),
])
.optional()
.title('Template header parameters')
.describe(
'Header parameter. ' +
'For text headers: type="text", value is the replacement text, parameterName is optional (for named params). ' +
'For media headers: type="image"|"video"|"document", url is the media URL. Documents may include a filename.'
),
templateBodyParams: z
.discriminatedUnion('type', [
z.object({ type: z.literal('positional'), values: z.array(z.string()) }),
z.object({ type: z.literal('named'), values: z.record(z.string()) }),
])
.optional()
.title('Template body parameters')
.describe(
'Body parameters. ' +
'For positional params ({{1}}, {{2}}, ...): type="positional", values is an ordered array of strings. ' +
'For named params ({{buyer_name}}): type="named", values is a record mapping param names to values.'
),
templateButtonParams: z
.array(
z.discriminatedUnion('type', [
z.object({ type: z.literal('url'), value: z.string() }),
z.object({ type: z.literal('quick_reply'), payload: z.string() }),
z.object({ type: z.literal('copy_code'), code: z.string() }),
z.object({ type: z.literal('skip') }),
])
)
.optional()
.title('Template button parameters')
.describe(
'Button parameters as an ordered array. ' +
'url: value is the URL suffix. quick_reply: payload is the callback data. ' +
'copy_code: code is the coupon code. skip: no parameter needed (for phone number buttons, etc.).'
),
botPhoneNumberId: z
.string()
.optional()
.title('Bot Phone Number ID')
.describe('Phone number ID to use as sender (uses the default phone number ID if not provided)'),
})
.title('Conversation Details')
.describe('Details of the conversation'),
}),
},
}
const defaultBotPhoneNumberId = {
title: 'Default Bot Phone Number ID',
description: 'Default Phone ID used by the bot for starting conversations',
}
export const INTEGRATION_NAME = 'whatsapp'
export const INTEGRATION_VERSION = '4.18.0'
export default new IntegrationDefinition({
name: INTEGRATION_NAME,
version: INTEGRATION_VERSION,
title: 'WhatsApp',
description: 'Send and receive messages through WhatsApp.',
icon: 'icon.svg',
readme: 'hub.md',
configurations: {
manual: {
title: 'Manual Configuration',
description: 'Manual Configuration, use your own Meta app (for advanced use cases only)',
schema: z
.object({
verifyToken: z
.string()
.min(1)
.secret()
.title('Verify Token')
.describe(
'Token used for verification when subscribing to webhooks on the Meta app (type any random string)'
),
accessToken: z
.string()
.min(1)
.secret()
.title('Access Token')
.describe('Access Token from a System Account that has permission to the Meta app'),
appId: z
.string()
.optional()
.title('App ID')
.describe(
"Your Meta app ID. Provide this together with the Client Secret to let Botpress automatically configure the webhook (callback URL, verify token and subscribed fields) on your Meta app during setup, so you don't have to configure it manually in the Meta dashboard. Optional: if left empty, you must configure the webhook yourself in the Meta app dashboard."
),
clientSecret: z
.string()
.secret()
.optional()
.title('Client Secret')
.describe(
'Your Meta app secret. Used to verify incoming webhook request signatures, and—when provided together with the App ID—to automatically configure the webhook on your Meta app during setup. Optional: if left empty, webhook signature verification is skipped and you must configure the webhook yourself in the Meta app dashboard.'
),
defaultBotPhoneNumberId: z
.string()
.min(1)
.title(defaultBotPhoneNumberId.title)
.describe(defaultBotPhoneNumberId.description),
whatsappBusinessAccountId: z
.string()
.secret()
.optional() // TODO remove optional next major version
.title('WABA ID')
.describe('Your Whatsapp business Account ID (will be required on the next major update)'),
})
.merge(commonConfigSchema),
},
sandbox: {
title: 'Sandbox',
description: 'Sandbox configuration, for testing purposes only',
schema: commonConfigSchema,
identifier: {
linkTemplateScript: 'sandboxLinkTemplate.vrl',
},
},
},
configuration: {
identifier: {
linkTemplateScript: 'linkTemplate.vrl',
required: true,
},
schema: commonConfigSchema,
},
identifier: {
extractScript: 'extract.vrl',
fallbackHandlerScript: 'fallbackHandler.vrl',
},
channels: {
channel: {
title: 'WhatsApp conversation',
description: 'Conversation between a WhatsApp user and the bot',
messages: {
...messages.defaults,
text: {
schema: messages.defaults.text.schema.extend({
value: z
.string()
.optional()
.title('value')
.describe('Underlying value of the message, if any (e.g. button payload, list reply payload, etc.)'),
}),
},
dropdown: {
schema: messages.defaults.dropdown.schema.extend({
buttonLabel: dropdownButtonLabelSchema,
}),
},
choice: {
schema: messages.defaults.choice.schema.extend({
buttonLabel: dropdownButtonLabelSchema,
}),
},
file: {
schema: messages.defaults.file.schema.extend({
filename: z.string().optional(),
}),
},
image: {
schema: messages.defaults.image.schema.extend({
caption: z.string().optional(),
}),
},
bloc: {
schema: z.object({
items: z.array(
z.discriminatedUnion('type', [
z.object({
type: z.literal('text'),
payload: z.object({
text: z.string(),
}),
}),
z.object({
type: z.literal('markdown'), // TODO Remove for 5.0.0
payload: z.object({
markdown: z.string(),
}),
}),
z.object({
type: z.literal('image'),
payload: z.object({
imageUrl: z.string(),
}),
}),
z.object({
type: z.literal('audio'),
payload: z.object({
audioUrl: z.string(),
}),
}),
z.object({
type: z.literal('video'),
payload: z.object({
videoUrl: z.string(),
}),
}),
z.object({
type: z.literal('file'),
payload: z.object({
fileUrl: z.string(),
title: z.string().optional(),
filename: z.string().optional(),
}),
}),
z.object({
type: z.literal('location'),
payload: z.object({
latitude: z.number(),
longitude: z.number(),
address: z.string().optional(),
title: z.string().optional(),
}),
}),
])
),
}),
},
},
message: {
tags: {
id: {
title: 'Message ID',
description: 'The WhatsApp message ID',
},
reaction: {
title: 'Reaction',
description: 'A reaction added to the message',
},
replyTo: {
title: 'Reply To',
description: 'The ID of the message that this message is a reply to',
},
referralSourceUrl: {
title: 'Referral Source URL',
description: 'The URL of the ad or content that led to the conversation',
},
referralSourceId: {
title: 'Referral Source ID',
description: 'The ID of the ad or content that led to the conversation',
},
echoCreationType: {
title: 'Echo Creation Type',
description: 'For echoed messages: the creation type reported by WhatsApp (e.g. "created_by_1p_bot")',
},
status: {
title: 'Delivery Status',
description: 'Latest WhatsApp delivery status reported via webhook (SENT, DELIVERED, READ, FAILED).',
},
},
},
conversation: {
tags: {
botPhoneNumberId: {
title: 'Bot Phone Number ID',
description: 'WhatsApp Phone Number ID of the bot',
},
userPhone: {
title: 'User Phone Number',
description: 'Phone number of the WhatsApp user having a conversation with the bot.',
},
userId: {
title: 'User ID',
description: 'WhatsApp stable user ID of the user having a conversation with the bot.',
},
},
},
},
},
user: {
tags: {
userId: {
title: 'User ID',
description: 'WhatsApp user ID',
},
name: {
title: 'Name',
description: 'WhatsApp user display name',
},
number: {
title: 'Phone Number',
description: 'WhatsApp phone number of the user',
},
username: {
title: 'Username',
description: 'WhatsApp username of the user (when opted in to username privacy)',
},
whatsappUserId: {
title: 'WhatsApp User ID',
description:
"WhatsApp's stable user ID (user_id) of the user, present in both opted-in and non-opted-in payloads",
},
},
},
actions: {
startConversation: {
...startConversationProps,
output: {
schema: z.object({
conversationId: z.string().title('Conversation ID').describe('ID of the conversation created'),
messageId: z.string().optional().title('Message ID').describe('ID of the message created'),
}),
},
},
sendTemplateMessage: {
title: 'Send Template Message',
description: 'Sends a WhatsApp Message Template to a user in an existing conversation',
input: startConversationProps.input,
output: {
schema: z.object({
conversationId: z.string().title('Conversation ID').describe('ID of the conversation created'),
messageId: z.string().optional().title('Message ID').describe('ID of the message created'),
}),
},
},
listTemplates: {
title: 'List Message Templates',
description:
'Lists WhatsApp message templates from the connected WhatsApp Business Account, including parameter schemas and approval status',
input: {
schema: z.object({
status: z
.enum(['APPROVED', 'PENDING', 'REJECTED', 'PAUSED', 'DISABLED', 'ARCHIVED', 'LIMIT_EXCEEDED', 'IN_APPEAL'])
.optional()
.title('Status Filter')
.describe('Filter templates by approval status. Returns all statuses if not specified.'),
name: z.string().optional().title('Template Name').describe('Filter templates by exact name match.'),
limit: z
.number()
.optional()
.default(20)
.title('Limit')
.describe('Maximum number of templates to return per page (default: 20, max: 100).'),
nextCursor: z
.string()
.optional()
.title('Next Cursor')
.describe('Cursor for fetching the next page of results. Obtained from a previous listTemplates call.'),
}),
},
output: {
schema: z.object({
templates: z
.array(
z.object({
id: z.string().title('Template ID').describe('Unique identifier of the template'),
name: z.string().title('Name').describe('Name of the message template'),
status: z.string().title('Status').describe('Approval status of the template'),
category: z
.string()
.title('Category')
.describe('Category of the template (e.g. MARKETING, UTILITY, AUTHENTICATION)'),
language: z.string().title('Language').describe('Language and locale code of the template'),
components: z
.array(
z.object({
type: z.string().title('Type').describe('Component type (HEADER, BODY, FOOTER, BUTTONS)'),
format: z
.string()
.optional()
.title('Format')
.describe('Format of the component (e.g. TEXT, IMAGE, VIDEO for HEADER)'),
text: z
.string()
.optional()
.title('Text')
.describe('Text content of the component, may contain {{N}} placeholders'),
buttons: z
.array(
z.object({
type: z
.string()
.title('Button Type')
.describe('Type of button (QUICK_REPLY, URL, PHONE_NUMBER, etc.)'),
text: z.string().optional().title('Button Text').describe('Label text of the button'),
url: z
.string()
.optional()
.title('Button URL')
.describe('URL for URL-type buttons, may contain {{1}} placeholder'),
phone_number: z
.string()
.optional()
.title('Phone Number')
.describe('Phone number for PHONE_NUMBER-type buttons'),
})
)
.optional()
.title('Buttons')
.describe('Array of button objects (only present for BUTTONS component type)'),
example: z
.record(z.unknown())
.optional()
.title('Example')
.describe('Example values for the component parameters'),
})
)
.title('Components')
.describe('Array of template components (HEADER, BODY, FOOTER, BUTTONS)'),
})
)
.title('Templates')
.describe('Array of message templates'),
nextCursor: z
.string()
.optional()
.title('Next Cursor')
.describe('Cursor for fetching the next page. Undefined if no more pages.'),
}),
},
},
},
events: {
messageSent: {
title: 'Message Sent',
description: 'Triggered when a message is sent',
schema: z.object({}),
},
messageDelivered: {
title: 'Message Delivered',
description: 'Triggered when a message is delivered to a user',
schema: z.object({}),
},
messageFailed: {
title: 'Message Failed',
description: 'Triggered when a message fails to be delivered',
schema: z.object({
error: z.string().describe('Error details describing why the message failed'),
}),
},
messageRead: {
title: 'Message Read',
description: 'Triggered when a user reads a message',
schema: z.object({}),
},
onMessageEchoReceived: {
title: 'Message Echo Received',
description:
'Triggered when an outbound message sent through another channel (e.g. a human agent) is echoed back via the webhook',
schema: z.object({}),
},
reactionAdded: {
title: 'Reaction Added',
description: 'Triggered when a user adds a reaction to a message',
schema: z.object({
reaction: z.string().title('Reaction').describe('The reaction that was added'),
messageId: z.string().title('Message ID').describe('ID of the message that was reacted to'),
userId: z.string().optional().title('User ID').describe('ID of the user who added the reaction'),
conversationId: z.string().optional().title('Conversation ID').describe('ID of the conversation'),
}),
},
reactionRemoved: {
title: 'Reaction Removed',
description: 'Triggered when a user removes a reaction from a message',
schema: z.object({
reaction: z.string().title('Reaction').describe('The reaction that was removed'),
messageId: z.string().title('Message ID').describe('ID of the message that was reacted to'),
userId: z.string().optional().title('User ID').describe('ID of the user who removed the reaction'),
conversationId: z.string().optional().title('Conversation ID').describe('ID of the conversation'),
}),
},
messageTemplateComponentsUpdate: {
title: 'Message Template Components Update',
description: 'Triggered when a template is edited',
schema: WhatsAppMessageTemplateComponentsUpdateValueSchema,
},
messageTemplateQualityUpdate: {
title: 'Message Template Quality Update',
description: "Triggered when a template's quality score changes",
schema: WhatsAppMessageTemplateQualityUpdateValueSchema,
},
messageTemplateStatusUpdate: {
title: 'Message Template Status Update',
description: 'Triggered when a template is approved, rejected or disabled',
schema: WhatsAppMessageTemplateStatusUpdateValueSchema,
},
templateCategoryUpdate: {
title: 'Template Category Update',
description:
'Triggered when the category of a WhatsApp template is changed — whether manually or by an automated process, or when such a change is about to occur.',
schema: WhatsAppTemplateCategoryUpdateValueSchema,
},
},
states: {
credentials: {
type: 'integration',
schema: z.object({
accessToken: z
.string()
.optional()
.title('Access token')
.describe('Access token used to authenticate requests to the WhatsApp Business Platform API'),
defaultBotPhoneNumberId: z
.string()
.optional()
.title(defaultBotPhoneNumberId.title)
.describe(defaultBotPhoneNumberId.description),
wabaId: z
.string()
.optional()
.title('WhatsApp Business Account ID')
.describe('WhatsApp Business Account ID used to subscribe to webhook events'),
}),
},
},
secrets: {
...posthogHelper.COMMON_SECRET_NAMES,
CLIENT_ID: {
description: 'The client ID of the OAuth Meta app',
},
CLIENT_SECRET: {
description: 'The client secret of the OAuth Meta app.',
},
OAUTH_CONFIG_ID: {
description: 'The OAuth configuration ID for the OAuth Meta app',
},
VERIFY_TOKEN: {
description: 'The verify token for the OAuth Meta App Webhooks subscription',
},
ACCESS_TOKEN: {
description: 'Access token for the internal Meta App',
},
NUMBER_PIN: {
description: '6 Digits Pin used for phone number registration',
},
SANDBOX_CLIENT_SECRET: {
description: 'The client secret of the Sandbox Meta app',
},
SANDBOX_VERIFY_TOKEN: {
description: 'The verify token for the Sandbox Meta App Webhooks subscription',
},
SANDBOX_ACCESS_TOKEN: {
description: 'Access token for the Sandbox Meta App',
},
SANDBOX_PHONE_NUMBER_ID: {
description: 'Phone number ID of the Sandbox WhatsApp Business profile',
},
},
entities: {
proactiveConversation: {
title: 'Proactive Conversation',
description: 'Proactive conversation with a WhatsApp user',
schema: startConversationProps.input.schema.shape['conversation'],
},
},
attributes: {
category: 'Communication & Channels',
guideSlug: 'whatsapp',
repo: 'botpress',
},
__advanced: {
toJSONSchemaOptions,
},
})
.extend(typingIndicator, () => ({ entities: {} }))
.extend(proactiveConversation, ({ entities }) => ({
entities: {
conversation: entities.proactiveConversation,
},
actions: {
getOrCreateConversation: {
name: 'startConversation',
title: startConversationProps.title,
description: startConversationProps.description,
},
},
}))
+4
View File
@@ -0,0 +1,4 @@
webhookId = to_string!(.webhookId)
webhookUrl = to_string!(.webhookUrl)
"{{ webhookUrl }}/oauth/wizard/start-confirm?state={{ webhookId }}"
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@botpresshub/whatsapp",
"description": "WhatsApp integration for Botpress",
"private": true,
"scripts": {
"build": "bp add -y && bp build",
"check:type": "tsc --noEmit",
"check:bplint": "bp lint",
"uploadSandboxScripts": "ts-node -T ../../scripts/upload-sandbox-scripts.ts --integrationPath='./'",
"test": "vitest --run"
},
"dependencies": {
"@botpress/client": "workspace:*",
"@botpress/sdk": "workspace:*",
"@botpress/sdk-addons": "workspace:*",
"awesome-phonenumber": "^7.5.0",
"axios": "^1.6.2",
"marked": "^15.0.1",
"preact": "^10.26.6",
"preact-render-to-string": "^6.5.13",
"whatsapp-api-js": "^5.3.0"
},
"devDependencies": {
"@botpress/cli": "workspace:*",
"@botpress/common": "workspace:*",
"@botpress/sdk": "workspace:*"
},
"bpDependencies": {
"typing-indicator": "../../interfaces/typing-indicator",
"proactive-conversation": "../../interfaces/proactive-conversation"
}
}
@@ -0,0 +1,13 @@
expectedSignature = split(to_string!(.headers."x-hub-signature-256"), "=")[1]
actualSignature = encode_base16(hmac(to_string!(.body), to_string!(.secrets.SANDBOX_CLIENT_SECRET)))
if actualSignature != expectedSignature {
null
} else {
body = parse_json!(.body)
value = body.entry[0].changes[0].value
if !is_nullish(value.statuses) && length!(value.statuses) > 0 {
value.statuses[0].recipient_id
} else {
value.contacts[0].wa_id
}
}
@@ -0,0 +1,10 @@
env = to_string!(.env)
shareableId = to_string!(.shareableId)
phoneNumber = if env == "production" {
"15817019840"
} else {
"15817021047"
}
"https://api.whatsapp.com/send?phone={{ phoneNumber }}&text={{ shareableId }}"
@@ -0,0 +1,2 @@
body = parse_json!(.body)
body.entry[0].changes[0].value.messages[0].text.body
@@ -0,0 +1,12 @@
import { listTemplates } from './list-templates'
import { startConversation, sendTemplateMessage } from './start-conversation'
import { startTypingIndicator, stopTypingIndicator } from './typing-indicator'
import * as bp from '.botpress'
export default {
startConversation,
sendTemplateMessage,
listTemplates,
startTypingIndicator,
stopTypingIndicator,
} as const satisfies bp.IntegrationProps['actions']
@@ -0,0 +1,49 @@
import { fetchTemplates } from '../misc/template-utils'
import { logForBotAndThrow } from '../misc/util'
import * as bp from '.botpress'
export const listTemplates: bp.IntegrationProps['actions']['listTemplates'] = async ({
ctx,
input,
client,
logger,
}) => {
if (ctx.configurationType === 'sandbox') {
logForBotAndThrow('Listing templates is not supported in sandbox mode', logger)
}
const response = await fetchTemplates(ctx, client, logger, {
fields: 'id,name,status,category,language,components',
limit: Math.min(input.limit ?? 20, 100),
status: input.status,
name: input.name,
after: input.nextCursor,
})
const templates = (response.data ?? []).map((template) => ({
id: template.id,
name: template.name,
status: template.status,
category: template.category,
language: template.language,
components: (template.components ?? []).map((comp) => ({
type: comp.type,
format: comp.format,
text: comp.text,
buttons: comp.buttons?.map((btn) => ({
type: btn.type,
text: btn.text,
url: btn.url,
phone_number: btn.phone_number,
})),
example: comp.example,
})),
}))
const nextCursor: string | undefined = response.paging?.cursors?.after
return {
templates,
nextCursor,
}
}
@@ -0,0 +1,165 @@
import { posthogHelper } from '@botpress/common'
import { INTEGRATION_NAME, INTEGRATION_VERSION } from 'integration.definition'
import { Language, Template } from 'whatsapp-api-js/messages'
import type { TemplateComponent } from 'whatsapp-api-js/types'
import { getDefaultBotPhoneNumberId, getAuthenticatedWhatsappClient } from '../auth'
import { safeFormatPhoneNumber } from '../misc/phone-number-to-whatsapp'
import {
generateSyntheticTemplateText,
parseTemplateVariablesJSON,
buildHeaderComponent,
buildBodyComponent,
buildBodyComponentFromLegacy,
buildButtonComponents,
} from '../misc/template-utils'
import type { TemplateBodyParams } from '../misc/types'
import { logForBotAndThrow } from '../misc/util'
import * as bp from '.botpress'
export const sendTemplateMessage: bp.IntegrationProps['actions']['sendTemplateMessage'] = async (props) => {
return startConversation({
...props,
type: 'startConversation',
})
}
export const startConversation: bp.IntegrationProps['actions']['startConversation'] = async ({
ctx,
input,
client,
logger,
}) => {
// Prevent the use of billable resources through the sandbox account
if (ctx.configurationType === 'sandbox') {
logForBotAndThrow('Sending template is not supported in sandbox mode', logger)
}
const {
userPhone,
templateName,
templateVariablesJson,
templateHeaderParams,
templateBodyParams,
templateButtonParams,
} = input.conversation
const botPhoneNumberId = input.conversation.botPhoneNumberId
? input.conversation.botPhoneNumberId
: await getDefaultBotPhoneNumberId(client, ctx).catch(() => {
logForBotAndThrow('No default bot phone number ID available', logger)
})
const templateLanguage = input.conversation.templateLanguage || 'en'
const templateApiComponents: TemplateComponent[] = []
let effectiveBodyParams: TemplateBodyParams | undefined = templateBodyParams
if (templateHeaderParams) {
templateApiComponents.push(buildHeaderComponent(templateHeaderParams))
}
if (templateBodyParams) {
const bodyComponent = buildBodyComponent(templateBodyParams)
if (bodyComponent) {
templateApiComponents.push(bodyComponent)
}
// TODO: Remove templateVariablesJson in the next major
} else if (templateVariablesJson) {
const variables = parseTemplateVariablesJSON(templateVariablesJson, logger)
const bodyComponent = buildBodyComponentFromLegacy(variables)
if (bodyComponent) {
templateApiComponents.push(bodyComponent)
}
// Convert legacy variables to TemplateBodyParams for synthetic text rendering
effectiveBodyParams = Array.isArray(variables)
? { type: 'positional' as const, values: variables.map((v) => String(v)) }
: {
type: 'named' as const,
values: Object.fromEntries(Object.entries(variables).map(([k, v]) => [k, String(v)])),
}
}
if (templateButtonParams && templateButtonParams.length > 0) {
templateApiComponents.push(...buildButtonComponents(templateButtonParams))
}
const formatPhoneNumberResponse = safeFormatPhoneNumber(userPhone)
if (formatPhoneNumberResponse.success === false) {
const distinctId = formatPhoneNumberResponse.error.id
await posthogHelper.sendPosthogEvent(
{
distinctId: distinctId ?? 'no id',
event: 'invalid_phone_number',
properties: {
from: 'action',
phoneNumber: userPhone,
},
},
{ integrationName: INTEGRATION_NAME, integrationVersion: INTEGRATION_VERSION, key: bp.secrets.POSTHOG_KEY }
)
const errorMessage = formatPhoneNumberResponse.error.message
logForBotAndThrow(`Failed to parse phone number "${userPhone}": ${errorMessage}`, logger)
}
const { conversation } = await client.getOrCreateConversation({
channel: 'channel',
tags: {
botPhoneNumberId,
userPhone: formatPhoneNumberResponse.phoneNumber,
},
})
const whatsapp = await getAuthenticatedWhatsappClient(client, ctx)
const language = new Language(templateLanguage)
const template = new Template(templateName, language, ...templateApiComponents)
const response = await whatsapp.sendMessage(botPhoneNumberId, userPhone, template)
if ('error' in response) {
const errorJSON = JSON.stringify(response.error)
logForBotAndThrow(
`Failed to send WhatsApp template "${templateName}" with language "${templateLanguage}" - Error: ${errorJSON}`,
logger
)
}
const whatsappMessageId = 'messages' in response ? response.messages[0]?.id : undefined
const syntheticMessage = await client
.createMessage({
origin: 'synthetic',
conversationId: conversation.id,
userId: ctx.botId,
tags: { ...(whatsappMessageId ? { id: whatsappMessageId } : {}) },
type: 'text',
payload: {
text: await generateSyntheticTemplateText(
ctx,
client,
logger,
templateName,
templateLanguage,
effectiveBodyParams,
templateHeaderParams
),
},
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : ''
logger.forBot().error(`Failed to Create synthetic message from template message - Error: ${message}`)
})
logger
.forBot()
.info(
`Successfully sent WhatsApp template "${templateName}" with language "${templateLanguage}"${
templateBodyParams
? ` using template variables: ${JSON.stringify(templateBodyParams)}`
: ' without template variables'
}`
)
return {
conversationId: conversation.id,
messageId: syntheticMessage ? syntheticMessage.message.id : undefined,
}
}
@@ -0,0 +1,69 @@
import { RuntimeError } from '@botpress/client'
import { Reaction } from 'whatsapp-api-js/messages'
import { getAuthenticatedWhatsappClient } from '../auth'
import * as bp from '.botpress'
export const startTypingIndicator: bp.IntegrationProps['actions']['startTypingIndicator'] = async ({
client,
ctx,
input,
logger,
}) => {
if (ctx.configuration.messageReadBehavior === 'none') {
return {}
}
const whatsapp = await getAuthenticatedWhatsappClient(client, ctx)
const { conversationId, messageId } = input
const { botPhoneNumberId, userPhone } = await _getConversationInfos(client, conversationId)
const { whatsappMessageId } = await _getMessageInfos(client, messageId)
const indicator = ctx.configuration.messageReadBehavior === 'typing_indicator' ? 'text' : undefined
// No await to avoid blocking
void whatsapp.markAsRead(botPhoneNumberId, whatsappMessageId, indicator).catch((e) => {
logger.forBot().error(`Error marking message as read and/or sending typing indicators: ${e ?? '[Unknown error]'}`)
})
if (ctx.configuration.typingIndicatorEmoji) {
void whatsapp
.sendMessage(botPhoneNumberId, userPhone, new Reaction(whatsappMessageId, '👀'))
.catch((e) => logger.forBot().error(`Error sending typing indicator emoji: ${e ?? '[Unknown error]'}`))
}
return {}
}
export const stopTypingIndicator: bp.IntegrationProps['actions']['stopTypingIndicator'] = async ({
client,
ctx,
input,
}) => {
if (ctx.configuration.messageReadBehavior === 'none' || !ctx.configuration.typingIndicatorEmoji) {
return {}
}
const whatsapp = await getAuthenticatedWhatsappClient(client, ctx)
const { conversationId, messageId } = input
const { botPhoneNumberId, userPhone } = await _getConversationInfos(client, conversationId)
const { whatsappMessageId } = await _getMessageInfos(client, messageId)
await whatsapp.sendMessage(botPhoneNumberId, userPhone, new Reaction(whatsappMessageId))
return {}
}
const _getConversationInfos = async (client: bp.Client, conversationId: string) => {
const { conversation } = await client.getConversation({
id: conversationId,
})
const { botPhoneNumberId, userPhone } = conversation.tags
if (!botPhoneNumberId || !userPhone) {
throw new RuntimeError('Missing tags in conversation tags')
}
return { botPhoneNumberId, userPhone }
}
const _getMessageInfos = async (client: bp.Client, messageId: string) => {
const { message } = await client.getMessage({
id: messageId,
})
const { id: whatsappMessageId } = message.tags
if (!whatsappMessageId) {
throw new RuntimeError('Missing WhatsApp message id in the message tags')
}
return { whatsappMessageId }
}
+318
View File
@@ -0,0 +1,318 @@
import { RuntimeError, z } from '@botpress/sdk'
import axios, { AxiosError } from 'axios'
import { WhatsAppAPI } from 'whatsapp-api-js'
import { WHATSAPP } from './misc/constants'
import { chunkArray } from './misc/util'
import * as bp from '.botpress'
const MAX_BUSINESSES_PER_REQUEST = 50
// Webhook fields the integration relies on. These are subscribed at the Meta app
// level so the app's configured callback URL receives the corresponding events.
const WEBHOOK_FIELDS = [
'messages',
'smb_message_echoes',
'message_template_status_update',
'message_template_quality_update',
'message_template_components_update',
'template_category_update',
] as const
const getWabaIdsFromTokenResponseSchema = z
.object({
data: z.object({
granular_scopes: z.array(
z.object({
scope: z.string(),
target_ids: z.array(z.string()).optional(),
})
),
}),
})
.passthrough()
export class MetaOauthClient {
private _clientId: string
private _clientSecret: string
private _version: string = 'v19.0'
public constructor(private _logger: bp.Logger) {
this._clientId = bp.secrets.CLIENT_ID
this._clientSecret = bp.secrets.CLIENT_SECRET
}
public async exchangeAuthorizationCodeForAccessToken(code: string, redirectUri?: string): Promise<string> {
const query = new URLSearchParams({
client_id: this._clientId,
client_secret: this._clientSecret,
code,
})
if (redirectUri) {
query.append('redirect_uri', redirectUri)
}
const res = await axios.get(`https://graph.facebook.com/${this._version}/oauth/access_token?${query.toString()}`)
const data = z
.object({
access_token: z.string(),
})
.parse(res.data)
return data.access_token
}
public async getWhatsappBusinessesFromToken(inputToken: string): Promise<{ id: string; name: string }[]> {
const query = new URLSearchParams({
input_token: inputToken,
access_token: bp.secrets.ACCESS_TOKEN,
})
const { data: dataDebugToken } = await axios.get(
`https://graph.facebook.com/${this._version}/debug_token?${query.toString()}`
)
const data = getWabaIdsFromTokenResponseSchema.safeParse(dataDebugToken).data?.data
if (!data) {
throw new RuntimeError('Invalid response from API when fetching WhatsApp Business Accounts IDs')
}
const businessIds = data.granular_scopes.find((item) => item.scope === 'whatsapp_business_messaging')?.target_ids
if (!businessIds || businessIds.length === 0) {
throw new RuntimeError('No WhatsApp Business Account found')
}
const dataBusinesses: { id: string; name: string }[] = []
for (const businessIdsChunk of chunkArray(businessIds, MAX_BUSINESSES_PER_REQUEST)) {
const { data: dataBusinessesChunk } = await axios.get(
`https://graph.facebook.com/${this._version}/?ids=${businessIdsChunk.join()}&fields=id,name`,
{
headers: {
Authorization: `Bearer ${inputToken}`,
},
}
)
Object.keys(dataBusinessesChunk).forEach((key) => dataBusinesses.push(dataBusinessesChunk[key]))
}
return dataBusinesses
}
public async getWhatsappNumbersFromBusiness(
businessId: string,
accessToken: string
): Promise<{ id: string; verifiedName: string; displayPhoneNumber: string }[]> {
const query = new URLSearchParams({
access_token: accessToken,
})
const { data } = await axios.get(
`https://graph.facebook.com/${this._version}/${businessId}/phone_numbers?${query.toString()}`
)
return data.data.map((item: { id: string; verified_name: string; display_phone_number: string }) => ({
id: item.id,
verifiedName: item.verified_name,
displayPhoneNumber: item.display_phone_number,
}))
}
public async registerNumber(numberId: string, accessToken: string) {
const query = new URLSearchParams({
access_token: accessToken,
messaging_product: 'whatsapp',
pin: bp.secrets.NUMBER_PIN,
})
try {
const { data } = await axios.post(
`https://graph.facebook.com/${this._version}/${numberId}/register?${query.toString()}`
)
if (!data.success) {
throw new Error('No Success')
}
} catch (e: unknown) {
if (e instanceof AxiosError) {
// 403 -> Number already registered
if (e.response?.status !== 403) {
this._logger
.forBot()
.error(
`(OAuth registration) Error registering the provided phone number ID: ${e.message} -> ${JSON.stringify(
e.response?.data
)}`
)
}
}
const errorMessage = e instanceof Error ? e.message : String(e)
this._logger
.forBot()
.error(`(OAuth registration) Error registering the provided phone number ID: ${errorMessage}`)
}
}
/**
* Configures the app-level webhook (callback URL, verify token and subscribed fields) on a
* user-owned Meta app via `POST /{app-id}/subscriptions`.
*
* This mirrors the WhatsApp > Configuration screen in the Meta dashboard, and is the app-level
* counterpart to `subscribeToWebhooks` (which subscribes a single WABA to the app). It requires
* an app access token (`{appId}|{appSecret}`), which is why both the App ID and Client Secret
* must be provided in the manual configuration. Meta calls back the `callbackUrl` with a
* verification challenge (using `verifyToken`) before persisting, so the Botpress webhook must
* already be live.
*/
public async configureAppWebhookSubscription({
appId,
appSecret,
verifyToken,
callbackUrl,
}: {
appId: string
appSecret: string
verifyToken: string
callbackUrl: string
}): Promise<void> {
const appAccessToken = `${appId}|${appSecret}`
try {
const { data } = await axios.post(
`${WHATSAPP.API_URL}/${appId}/subscriptions`,
{
object: 'whatsapp_business_account',
callback_url: callbackUrl,
verify_token: verifyToken,
fields: WEBHOOK_FIELDS.join(','),
},
{ params: { access_token: appAccessToken } }
)
if (!data?.success) {
throw new Error('No Success')
}
} catch (e: unknown) {
if (e instanceof AxiosError) {
this._logger
.forBot()
.error(
`Error configuring app webhook subscription for app ${appId}: ${e.message} -> ${JSON.stringify(
e.response?.data
)}`
)
}
throw new RuntimeError(
'Failed to automatically configure the webhook on your Meta app. Please verify your App ID and Client Secret, or configure the webhook manually in the Meta app dashboard.'
)
}
}
public async subscribeToWebhooks(wabaId: string, accessToken: string) {
try {
const { data } = await axios.post(
`https://graph.facebook.com/${this._version}/${wabaId}/subscribed_apps`,
{},
{
headers: {
Authorization: 'Bearer ' + accessToken,
},
}
)
if (!data.success) {
throw new Error('No Success')
}
} catch (e: unknown) {
if (e instanceof AxiosError) {
this._logger
.forBot()
.error(
`(OAuth registration) Error subscribing to webhooks for WABA ${wabaId}: ${e.message} -> ${e.response?.data}`
)
}
throw new Error('Issue subscribing to Webhooks for WABA, please try again.')
}
}
}
export const getAccessToken = async (client: bp.Client, ctx: bp.Context): Promise<string> => {
let accessToken: string | undefined
if (ctx.configurationType === 'manual') {
accessToken = ctx.configuration.accessToken
} else if (ctx.configurationType === 'sandbox') {
accessToken = bp.secrets.SANDBOX_ACCESS_TOKEN
} else {
const { state } = await client.getState({ type: 'integration', name: 'credentials', id: ctx.integrationId })
accessToken = state.payload.accessToken
}
if (!accessToken) {
throw new RuntimeError('Access token not found in saved credentials')
}
return accessToken
}
export const getAuthenticatedWhatsappClient = async (client: bp.Client, ctx: bp.Context): Promise<WhatsAppAPI> => {
const token = await getAccessToken(client, ctx)
return new WhatsAppAPI({ token, secure: false, v: WHATSAPP.API_VERSION })
}
export function getVerifyToken(ctx: bp.Context): string {
// Should normally be verified in the fallbackHandler script with OAuth and Sandbox
let verifyToken: string
if (ctx.configurationType === 'manual') {
verifyToken = ctx.configuration.verifyToken
} else if (ctx.configurationType === 'sandbox') {
verifyToken = bp.secrets.SANDBOX_VERIFY_TOKEN
} else {
verifyToken = bp.secrets.VERIFY_TOKEN
}
return verifyToken
}
export const getClientSecret = (ctx: bp.Context): string | undefined => {
let value: string | undefined
if (ctx.configurationType === 'manual') {
value = ctx.configuration.clientSecret
} else if (ctx.configurationType === 'sandbox') {
value = bp.secrets.SANDBOX_CLIENT_SECRET
} else {
value = bp.secrets.CLIENT_SECRET
}
return value?.length ? value : undefined
}
export const getDefaultBotPhoneNumberId = async (client: bp.Client, ctx: bp.Context) => {
let defaultBotPhoneNumberId: string | undefined
if (ctx.configurationType === 'manual') {
defaultBotPhoneNumberId = ctx.configuration.defaultBotPhoneNumberId
} else if (ctx.configurationType === 'sandbox') {
defaultBotPhoneNumberId = bp.secrets.SANDBOX_PHONE_NUMBER_ID
} else {
const { state } = await client.getState({ type: 'integration', name: 'credentials', id: ctx.integrationId })
defaultBotPhoneNumberId = state.payload.defaultBotPhoneNumberId
}
if (!defaultBotPhoneNumberId) {
throw new RuntimeError('Default bot phone number ID not found in saved credentials')
}
return defaultBotPhoneNumberId
}
export const getWabaId = async (client: bp.Client, ctx: bp.Context) => {
let businessAccountId: string | undefined
if (ctx.configurationType === 'manual') {
businessAccountId = ctx.configuration.whatsappBusinessAccountId
} else if (ctx.configurationType === 'sandbox') {
throw new RuntimeError('The WabaId should not be recovered from sandbox configuration type')
} else {
const { state } = await client.getState({ type: 'integration', name: 'credentials', id: ctx.integrationId })
businessAccountId = state.payload.wabaId
}
if (!businessAccountId) {
throw new RuntimeError('WhatsApp Business Account Id not found in saved credentials')
}
return businessAccountId
}
@@ -0,0 +1,391 @@
import { posthogHelper } from '@botpress/common'
import { RuntimeError } from '@botpress/sdk'
import { INTEGRATION_NAME, INTEGRATION_VERSION } from 'integration.definition'
import {
Text,
Audio,
Document,
Image,
Sticker,
Video,
Location,
Contacts,
Interactive,
Template,
Reaction,
} from 'whatsapp-api-js/messages'
import { getAuthenticatedWhatsappClient } from '../auth'
import { WHATSAPP } from '../misc/constants'
import { convertMarkdownToWhatsApp } from '../misc/markdown-to-whatsapp-rtf'
import { splitTextMessageIfNeeded } from '../misc/split-text-message'
import { reportIssueAndThrow, sleep } from '../misc/util'
import { repeat } from '../repeat'
import * as card from './message-types/card'
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
export const channel: bp.IntegrationProps['channels']['channel'] = {
messages: {
text: async ({ payload, ...props }) => {
if (payload.text.trim().length === 0) {
props.logger
.forBot()
.warn(`Message ${props.message.id} skipped: payload text must contain at least one non-invisible character.`)
return
}
const text = convertMarkdownToWhatsApp(payload.text)
const messagesToSend = splitTextMessageIfNeeded(text)
if (messagesToSend.length === 0) {
await posthogHelper.sendPosthogEvent(
{
distinctId: props.conversation.tags.userPhone ?? props.conversation.id ?? props.message.id ?? 'no id',
event: 'no_message_to_send',
properties: {
from: 'channel',
messageId: props.message.id,
},
},
{ integrationName: INTEGRATION_NAME, integrationVersion: INTEGRATION_VERSION, key: bp.secrets.POSTHOG_KEY }
)
props.logger.forBot().warn(`Message ${props.message.id} skipped: no message to send.`)
return
}
for (let i = 0; i < messagesToSend.length; i++) {
if (i > 0) {
// Adding delay between parts to avoid messages being sent out of order
await sleep(PART_DELAY_MS)
}
await _send({
client: props.client,
ctx: props.ctx,
conversation: props.conversation,
message: new Text(messagesToSend[i]!),
ack: props.ack,
logger: props.logger,
})
}
},
image: async ({ payload, logger, ...props }) => {
await _send({
...props,
logger,
message: await image.generateOutgoingMessage({
payload,
logger,
}),
})
},
audio: async ({ payload, ...props }) => {
await _send({
...props,
message: new Audio(payload.audioUrl.trim(), false),
})
},
video: async ({ payload, ...props }) => {
await _send({
...props,
message: new Video(payload.videoUrl.trim(), false),
})
},
file: async ({ payload, ...props }) => {
const title = payload.title?.trim()
const url = payload.fileUrl.trim()
const inputFilename = payload.filename?.trim()
let filename = inputFilename || title || 'file'
const fileExtension = _extractFileExtension(filename)
if (!fileExtension) {
filename += _extractFileExtension(url) ?? ''
}
await _send({
...props,
message: new Document(url, false, title, filename),
})
},
location: async ({ payload, ...props }) => {
await _send({
...props,
message: new Location(payload.longitude, payload.latitude),
})
},
carousel: async (props) => {
await _sendMany({ ...props, generator: carousel.generateOutgoingMessages(props) })
},
card: async ({ payload, logger, ...props }) => {
await _sendMany({ ...props, logger, generator: card.generateOutgoingMessages(payload, logger) })
},
dropdown: async ({ payload, logger, ...props }) => {
await _sendMany({
...props,
logger,
generator: dropdown.generateOutgoingMessages({ payload, logger }),
})
},
choice: async ({ payload, logger, ...props }) => {
if (payload.options.length <= WHATSAPP.INTERACTIVE_MAX_BUTTONS_COUNT) {
await _sendMany({
...props,
logger,
generator: choice.generateOutgoingMessages({ payload, logger }),
})
} else {
// If choice options exceeds the maximum number of buttons allowed by WhatsApp we use a dropdown instead to avoid buttons being split into multiple groups with a repeated message.
await _sendMany({
...props,
logger,
generator: dropdown.generateOutgoingMessages({ payload, logger }),
})
}
},
bloc: async ({ payload, ...props }) => {
if (!payload.items) {
return
}
for (const item of payload.items) {
if (!item) {
continue
}
switch (item.type) {
case 'text':
if (item.payload.text.trim().length === 0) {
props.logger
.forBot()
.warn(
`Message ${props.message.id} skipped: payload text must contain at least one non-invisible character.`
)
break
}
const blocText = convertMarkdownToWhatsApp(item.payload.text)
const messagesToSend = splitTextMessageIfNeeded(blocText)
for (let i = 0; i < messagesToSend.length; i++) {
if (i > 0) {
// Adding delay between parts to avoid messages being sent out of order
await sleep(PART_DELAY_MS)
}
await _send({
client: props.client,
ctx: props.ctx,
conversation: props.conversation,
message: new Text(messagesToSend[i]!),
ack: props.ack,
logger: props.logger,
})
}
break
case 'image':
await _send({
...props,
message: await image.generateOutgoingMessage({ payload: item.payload, logger: props.logger }),
})
break
case 'audio':
await _send({ ...props, message: new Audio(item.payload.audioUrl.trim(), false) })
break
case 'video':
await _send({
...props,
message: new Video(item.payload.videoUrl.trim(), false),
})
break
case 'file':
const title = item.payload.title?.trim()
const url = item.payload.fileUrl.trim()
const inputFilename = item.payload.filename?.trim()
let filename = inputFilename || title || 'file'
const fileExtension = _extractFileExtension(filename)
if (!fileExtension) {
filename += _extractFileExtension(url) ?? ''
}
await _send({ ...props, message: new Document(url, false, title, filename) })
break
case 'location':
await _send({ ...props, message: new Location(item.payload.longitude, item.payload.latitude) })
break
default:
props.logger.forBot().warn('The type passed in bloc is not supported')
continue
}
}
},
},
}
type OutgoingMessage =
| Text
| Audio
| Document
| Image
| Sticker
| Video
| Location
| Contacts
| Interactive
| Template
| Reaction
| RawInteractiveMessage
type SendMessageProps = {
client: bp.Client
ctx: bp.AnyMessageProps['ctx']
conversation: bp.AnyMessageProps['conversation']
ack: bp.AnyMessageProps['ack']
logger: bp.AnyMessageProps['logger']
message?: OutgoingMessage
}
// From https://developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes#throttling-errors
// Only contains codes for errors that can be recovered from by waiting
const THROTTLING_CODES = new Set([
80007, // WABA/app rate limit
130429, // Cloud API throughput reached
131056, // Pair rate limit (same sender↔recipient)
])
function backoffDelayMs(attempt: number) {
// Helper function for backoff delay with jitter. Uses Meta recommendation for exponential curve
// https://developers.facebook.com/docs/whatsapp/cloud-api/overview/?locale=en_US#pair-rate-limits
const baseMs = Math.pow(4, attempt) * 1000
const jitter = 0.75 + Math.random() * 0.5
return Math.floor(baseMs * jitter)
}
const MAX_ATTEMPT = 3
async function _send({ client, ctx, conversation, logger, message, ack }: SendMessageProps) {
if (!message) {
logger.forBot().debug('No message to send')
return
}
const whatsapp = await getAuthenticatedWhatsappClient(client, ctx)
const botPhoneNumberId = conversation.tags.botPhoneNumberId
// For users opted in to username privacy, no phone number is available and the stable
// WhatsApp user_id is used as the recipient instead.
const recipient = conversation.tags.userPhone ?? conversation.tags.userId
const messageType = message._type
if (!botPhoneNumberId) {
reportIssueAndThrow(logger, {
code: 'whatsapp_missing_bot_phone_number',
category: 'configuration',
title: 'Missing bot phone number ID',
description:
"Cannot send message to WhatsApp because the bot phone number ID wasn't set in the conversation tags",
data: { messageType: { raw: messageType } },
})
}
if (!recipient) {
reportIssueAndThrow(logger, {
code: 'whatsapp_missing_recipient',
category: 'other',
title: 'Missing WhatsApp recipient',
description:
"Cannot send message to WhatsApp because neither the user's phone number nor user ID is set in the conversation tags",
data: { messageType: { raw: messageType } },
})
}
const feedback = await repeat(
async (i) => {
if (i > 0) {
logger.forBot().info(`Retrying to send ${messageType} message to WhatsApp (attempt ${i + 1}/${MAX_ATTEMPT})...`)
}
const result = await whatsapp.sendMessage(botPhoneNumberId, recipient, message)
const repeat = 'error' in result && THROTTLING_CODES.has(result.error?.code ?? 0)
return {
repeat,
result,
}
},
{
maxIterations: MAX_ATTEMPT,
backoff: backoffDelayMs,
}
)
if ('error' in feedback) {
const reason = feedback.error?.message ?? 'Unknown error'
const errorCode = feedback.error?.code
reportIssueAndThrow(logger, {
code: 'whatsapp_send_message_failed',
category: 'other',
title: `Failed to send ${messageType} message to WhatsApp`,
description: `WhatsApp rejected the ${messageType} message sent from the bot. Reason: ${reason}`,
groupBy: ['whatsapp_send_message_failed', String(errorCode ?? 'unknown')],
data: {
messageType: { raw: messageType },
reason: { raw: reason },
errorCode: { raw: String(errorCode ?? 'unknown') },
},
throwMessage: `Failed to send ${messageType} message to WhatsApp: ${reason}`,
})
}
if (!('messages' in feedback)) {
reportIssueAndThrow(logger, {
code: 'whatsapp_no_message_id_returned',
category: 'other',
title: `WhatsApp returned no message ID for the sent ${messageType} message`,
description: `A ${messageType} message from the bot was sent to WhatsApp but no messages were found in their response. Response: ${JSON.stringify(feedback)}`,
data: {
messageType: { raw: messageType },
response: { raw: JSON.stringify(feedback) },
},
throwMessage: `WhatsApp returned no message ID for the sent ${messageType} message, so delivery could not be confirmed`,
})
}
logger.forBot().debug(`Successfully sent ${messageType} message from bot to WhatsApp:`, message)
await ack({ tags: { id: feedback.messages[0].id } })
}
async function _sendMany({
client,
ctx,
conversation,
ack,
generator,
logger,
}: Omit<SendMessageProps, 'message'> & { generator: Generator<OutgoingMessage, void, unknown> }) {
try {
for (const message of generator) {
// Sending multiple messages in sequence does not guarantee delivery order on the client-side.
// In order for messages to appear in order on the client side, adding some sleep in between each seems to work.
await sleep(1000)
await _send({ ctx, conversation, ack, message, logger, client })
}
} catch (err) {
if (err instanceof RuntimeError) {
// _send already reported the issue; just propagate so the failure isn't swallowed.
throw err
}
const reason = err instanceof Error ? err.message : String(err)
reportIssueAndThrow(logger, {
code: 'whatsapp_generate_messages_failed',
category: 'other',
title: 'Failed to generate messages for sending to WhatsApp',
description: `An error occurred while generating messages to send to WhatsApp. Reason: ${reason}`,
data: { reason: { raw: reason } },
throwMessage: `Failed to send messages to WhatsApp: ${reason}`,
})
}
}
function _extractFileExtension(filename: string): string | undefined {
const filenameParts = filename.split('.')
return filenameParts.length > 1 ? `.${filenameParts.at(-1)}` : undefined
}
@@ -0,0 +1,6 @@
import { channel } from './channel'
import * as bp from '.botpress'
export default {
channel,
} satisfies bp.IntegrationProps['channels']
@@ -0,0 +1,226 @@
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 button from './interactive/button'
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' }
type ActionPostback = SDKAction & { action: 'postback' }
type Action = ActionSay | ActionURL | ActionPostback
type ActionsChunk = ReturnType<typeof chunkArray<ActionSay | ActionPostback>>[number]
export function* generateOutgoingMessages(card: Card, logger: bp.Logger) {
const actions = card.actions
if (actions.length === 0) {
for (const m of _renderActionlessCard(card)) {
yield m
}
return
}
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
}
isFirstMessage = false
}
}
type ActionRun = { kind: 'url'; actions: ActionURL[] } | { kind: 'button'; actions: Array<ActionSay | ActionPostback> }
// 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 runs
}
// 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 {
yield new Text(convertMarkdownToWhatsApp(card.title))
}
if (card.subtitle) {
yield new Text(convertMarkdownToWhatsApp(card.subtitle))
}
}
function* _generateButtonInteractiveMessages(
card: Card,
actions: Array<ActionSay | ActionPostback>,
logger: bp.Logger,
opts: { attachContextToFirst: boolean }
) {
const [firstChunk, ...followingChunks] = chunkArray(actions, WHATSAPP.INTERACTIVE_MAX_BUTTONS_COUNT)
if (firstChunk) {
const actionButtons = _createActionButtons(firstChunk)
if (actionButtons) {
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')
}
}
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))
}
}
function _createActionButtons(actionsChunk: ActionsChunk): ActionButtons | undefined {
const buttons = _createButtons(actionsChunk)
if (hasAtleastOne(buttons)) {
return new ActionButtons(...buttons)
}
return undefined
}
function _createButtons(nonURLActions: Array<ActionSay | ActionPostback>) {
const buttons: Button[] = []
for (const action of nonURLActions) {
buttons.push(button.create({ id: action.value, title: action.label }))
}
return buttons
}
function* _generateCTAUrlInteractiveMessages(
card: Card,
actions: ActionURL[],
_logger: bp.Logger,
opts: { attachContextToFirst: boolean }
) {
let isFirst = true
for (const action of actions) {
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),
_truncatedBody(formatCardBodyText(card) ?? action.value)
)
} else {
yield new Interactive(new ActionCTA(action.label, action.value), _truncatedBody(ZERO_WIDTH_SPACE))
}
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,
})
)
}
@@ -0,0 +1,186 @@
import { chunkArray } from '../../misc/util'
import {
buildCtaUrlPayload,
buildQuickReplyPayload,
formatCardBodyText,
generateOutgoingMessages as renderSingleCard,
} from './card'
import { RawInteractiveMessage } from './raw-interactive'
import { channels } from '.botpress'
import * as bp from '.botpress'
export type Carousel = channels.channel.carousel.Carousel
type Card = channels.channel.card.Card
type CarouselItem = Carousel['items'][number]
type Action = Card['actions'][number]
// Per Meta's Interactive Media Carousel spec
const MIN_CARDS = 2
const MAX_CARDS = 10
const CARD_BODY_MAX_LENGTH = 160
const MAX_QR_BUTTONS_PER_CARD = 2
const ZERO_WIDTH_SPACE = ''
type CarouselMessageProps = Parameters<
NonNullable<bp.IntegrationProps['channels']['channel']['messages']>['carousel']
>[0]
export function* generateOutgoingMessages(props: CarouselMessageProps) {
const { logger, message, payload } = props
const result = _partitionForCarousel(payload.items)
if (result.mode === 'fallback') {
logger
.forBot()
.warn(
`Message ${message.id}: falling back to per-card rendering instead of a native WhatsApp carousel: ${result.reason}`
)
for (const item of payload.items) {
for (const m of _safeRenderCard(item, logger)) yield m
}
return
}
for (const partition of result.partitions) {
yield _buildNativeCarousel(partition)
}
}
function _safeRenderCard(item: CarouselItem, logger: bp.Logger) {
try {
return [...renderSingleCard(item, logger)]
} catch (err) {
const label = item.title?.trim() || '<untitled>'
logger.forBot().error(`Skipped carousel card "${label}" because it failed to render:`, err)
return []
}
}
// ─── Partitioning ───────────────────────────────────────────────────
const _isUrlAction = (a: Action): boolean => a.action === 'url'
const _isQuickReplyAction = (a: Action): boolean => a.action === 'postback' || a.action === 'say'
// 'U' = url, 'Q' = quick-reply (postback / say); the array preserves action order
type CardShape = readonly ('U' | 'Q')[]
const _getCardShape = (card: Card): CardShape => card.actions.map((a) => (_isUrlAction(a) ? 'U' : 'Q'))
// A card slot must be exactly one URL or 1..MAX_QR_BUTTONS_PER_CARD quick-replies; no mixing, no empty.
const _isCarouselEligible = (shape: CardShape): boolean => {
if (shape.length === 0) return false
if (shape.length === 1 && shape[0] === 'U') return true
return shape.length <= MAX_QR_BUTTONS_PER_CARD && shape.every((t) => t === 'Q')
}
const _hasDuplicateQuickReplyIds = (cards: Carousel['items']): boolean => {
const ids = cards.flatMap((c) => c.actions.filter(_isQuickReplyAction).map((a) => a.value))
return new Set(ids).size !== ids.length
}
type PartitionResult =
| { mode: 'native'; partitions: ReadonlyArray<Carousel['items']> }
| { mode: 'fallback'; reason: string }
// All-or-nothing: if any card can't slot in cleanly, the whole carousel
// falls back to per-card. Reasons are tester-facing (logged as warnings).
const _partitionForCarousel = (items: Carousel['items']): PartitionResult => {
const shapes = items.map(_getCardShape)
const noImageIdx = items.findIndex((item) => !item.imageUrl)
if (noImageIdx !== -1) {
return {
mode: 'fallback',
reason: `card #${noImageIdx} has no imageUrl (every carousel card needs an image header)`,
}
}
const ineligibleIdx = shapes.findIndex((s) => !_isCarouselEligible(s))
if (ineligibleIdx !== -1) {
const shape = shapes[ineligibleIdx]!.join('') || 'no actions'
return {
mode: 'fallback',
reason: `card #${ineligibleIdx} has shape "${shape}" which isn't carousel-eligible (must be 1 url, or 1-${MAX_QR_BUTTONS_PER_CARD} quick-replies, never mixed)`,
}
}
const overlongIdx = items.findIndex((item) => {
const body = formatCardBodyText(item)
return body !== undefined && body.length > CARD_BODY_MAX_LENGTH
})
if (overlongIdx !== -1) {
return {
mode: 'fallback',
reason: `card #${overlongIdx} body exceeds ${CARD_BODY_MAX_LENGTH} characters`,
}
}
// Group consecutive cards with identical shapes
type Run = { fingerprint: string; items: Carousel['items'] }
const runs: Run[] = []
for (let i = 0; i < items.length; i++) {
const fingerprint = shapes[i]!.join('')
const last = runs[runs.length - 1]
if (last && last.fingerprint === fingerprint) {
last.items.push(items[i]!)
} else {
runs.push({ fingerprint, items: [items[i]!] })
}
}
const partitions: Carousel['items'][] = []
for (const run of runs) {
for (const chunk of chunkArray(run.items, MAX_CARDS)) {
partitions.push(chunk)
}
}
if (partitions.some((p) => p.length < MIN_CARDS)) {
return {
mode: 'fallback',
reason: `partitioning would leave at least one single-card stranger between native carousels (rendering all ${items.length} cards individually instead)`,
}
}
// Per-card fallback dodges this: ids only need to be unique within one bubble
if (partitions.some(_hasDuplicateQuickReplyIds)) {
return {
mode: 'fallback',
reason: 'duplicate quick-reply button ids across cards (Meta requires unique ids within a carousel message)',
}
}
return { mode: 'native', partitions }
}
// ─── Native-carousel construction ───────────────────────────────────
const _buildCtaUrlSlot = (card: Card, idx: number) => ({
card_index: idx,
...buildCtaUrlPayload({
imageUrl: card.imageUrl!.trim(),
bodyText: formatCardBodyText(card),
displayText: card.actions.find(_isUrlAction)!.label,
url: card.actions.find(_isUrlAction)!.value,
}),
})
const _buildQuickReplySlot = (card: Card, idx: number) => ({
card_index: idx,
...buildQuickReplyPayload({
imageUrl: card.imageUrl!.trim(),
bodyText: formatCardBodyText(card),
buttons: card.actions.filter(_isQuickReplyAction).map((a) => ({ id: a.value, title: a.label })),
}),
})
const _buildNativeCarousel = (items: Carousel['items']): RawInteractiveMessage => {
const cards = items.map((item, idx) =>
item.actions.some(_isUrlAction) ? _buildCtaUrlSlot(item, idx) : _buildQuickReplySlot(item, idx)
)
return new RawInteractiveMessage({
type: 'carousel',
body: { text: ZERO_WIDTH_SPACE }, // body is required; ZWSP keeps the strip bare
action: { cards },
})
}
@@ -0,0 +1,47 @@
import { Text, Interactive, ActionButtons, Button } from 'whatsapp-api-js/messages'
import { WHATSAPP } from '../../misc/constants'
import { convertMarkdownToWhatsApp } from '../../misc/markdown-to-whatsapp-rtf'
import { chunkArray, hasAtleastOne, truncate } from '../../misc/util'
import * as body from './interactive/body'
import * as button from './interactive/button'
import * as bp from '.botpress'
type Choice = bp.channels.channel.choice.Choice
type Option = Choice['options'][number]
export function* generateOutgoingMessages({
payload: { text, options },
logger,
}: {
payload: Choice
logger: bp.Logger
}) {
if (options.length === 0) {
yield new Text(convertMarkdownToWhatsApp(text))
} else {
const chunks = chunkArray(options, WHATSAPP.INTERACTIVE_MAX_BUTTONS_COUNT)
if (options.length > WHATSAPP.INTERACTIVE_MAX_BUTTONS_COUNT) {
logger
.forBot()
.info(
`Splitting ${options.length} choices into groups of ${WHATSAPP.INTERACTIVE_MAX_BUTTONS_COUNT} buttons each due to a limitation of WhatsApp.`
)
}
for (const chunk of chunks) {
const buttons: Button[] = chunk.map(_createButton)
const actionButtons = hasAtleastOne(buttons) ? new ActionButtons(...buttons) : undefined
if (!actionButtons) {
logger.debug('No buttons in chunk, skipping')
continue
}
yield new Interactive(actionButtons, body.create(text))
}
}
}
function _createButton(option: Option) {
const safeValue = option.value.trim() // WhatsApp doesn't allow trailing spaces in button IDs
return button.create({ id: safeValue, title: truncate(option.label, WHATSAPP.BUTTON_LABEL_MAX_LENGTH) })
}
@@ -0,0 +1,50 @@
import { Text, Interactive, ActionList, ListSection, Row } from 'whatsapp-api-js/messages'
import { convertMarkdownToWhatsApp } from '../../misc/markdown-to-whatsapp-rtf'
import { chunkArray, hasAtleastOne, truncate } from '../../misc/util'
import * as body from './interactive/body'
import * as bp from '.botpress'
type Dropdown = bp.channels.channel.dropdown.Dropdown
const INTERACTIVE_MAX_ACTIONS_COUNT = 10
const ACTION_LABEL_MAX_LENGTH = 24
export function* generateOutgoingMessages({
payload: { text, options, buttonLabel },
logger,
}: {
payload: Dropdown
logger: bp.Logger
}) {
if (options.length === 0) {
yield new Text(convertMarkdownToWhatsApp(text))
} else {
const chunks = chunkArray(options, INTERACTIVE_MAX_ACTIONS_COUNT)
if (options.length > INTERACTIVE_MAX_ACTIONS_COUNT) {
logger
.forBot()
.info(
`Splitting ${options.length} dropdown options into groups of ${INTERACTIVE_MAX_ACTIONS_COUNT} actions each due to a limitation of WhatsApp.`
)
}
for (const chunk of chunks) {
const rows: Row[] = chunk.map(
(o) => new Row(o.value.substring(0, 200), truncate(o.label, ACTION_LABEL_MAX_LENGTH), ' ')
)
if (!hasAtleastOne(rows)) {
logger.debug('No rows in chunk, skipping')
continue
}
const section = new ListSection(
truncate(text, ACTION_LABEL_MAX_LENGTH),
...rows // NOTE: The description parameter is optional as per WhatsApp's documentation, but they have a bug that actually enforces the description to be a non-empty string.
)
const actionList = new ActionList(buttonLabel ?? 'Choose...', section)
yield new Interactive(actionList, body.create(text))
}
}
}
@@ -0,0 +1,117 @@
import axios from 'axios'
import * as WhatsappMessages from 'whatsapp-api-js/messages'
import * as bp from '.botpress'
type Image = bp.channels.channel.image.Image
type ImageMessageProps = {
payload: Image
logger: bp.Logger
}
type ImageDimensions = {
width: number
height: number
}
const MAX_STICKER_FILE_SIZE = 512 * 1024 // 512 KB
function _parseWebPDimensions(buffer: Buffer): ImageDimensions | undefined {
if (buffer.toString('utf8', 0, 4) !== 'RIFF' || buffer.toString('utf8', 8, 12) !== 'WEBP') {
return undefined
}
const chunkHeader = buffer.toString('utf8', 12, 16)
if (chunkHeader === 'VP8 ') {
// Lossy format
return {
width: buffer.readUInt16LE(26),
height: buffer.readUInt16LE(28),
}
}
if (chunkHeader === 'VP8L') {
// Lossless format
const b0 = buffer[21]
const b1 = buffer[22]
const b2 = buffer[23]
const b3 = buffer[24]
if (!(b0 && b1 && b2 && b3)) {
return undefined
}
const width = 1 + (((b1 & 0x3f) << 8) | b0)
const height = 1 + (((b3 & 0xf) << 10) | (b2 << 2) | ((b1 & 0xc0) >> 6))
return { width, height }
}
if (chunkHeader === 'VP8X') {
// Extended format
const width = 1 + buffer.readUIntLE(24, 3) // 3 bytes: offset 2426
const height = 1 + buffer.readUIntLE(27, 3) // 3 bytes: offset 2729
return { width, height }
}
return undefined
}
/**
* Generates an appropriate outgoing message (Image or Sticker) based on file type and constraints
*/
export async function generateOutgoingMessage({
payload,
logger,
}: ImageMessageProps): Promise<WhatsappMessages.Image | WhatsappMessages.Sticker | undefined> {
const url = payload.imageUrl.trim()
if (url.toLowerCase().endsWith('.webp')) {
return await _generateSticker(payload, logger)
} else {
return _generateImage(payload, logger)
}
}
function _generateImage(payload: Image, logger: bp.Logger): WhatsappMessages.Image | undefined {
logger.forBot().debug('Sending WhatsApp Image')
const url = payload.imageUrl.trim()
const caption = payload.caption
return new WhatsappMessages.Image(url, false, caption)
}
async function _generateSticker(payload: Image, logger: bp.Logger): Promise<WhatsappMessages.Sticker | undefined> {
// Check if the image is a valid WebP format and meets WhatsApp requirements,
// as invalid WebP images (stickers) are silently ignored by WhatsApp.
const url = payload.imageUrl.trim()
const downloadResponse = await axios.get(url, { responseType: 'arraybuffer' })
const buffer = Buffer.from(downloadResponse.data)
if (buffer.length > MAX_STICKER_FILE_SIZE) {
logger
.forBot()
.warn(
`Image is too big for a sticker. Current size is ${buffer.length} bytes. Must be smaller than ${MAX_STICKER_FILE_SIZE} bytes`
)
return undefined
}
let dimensions: ImageDimensions | undefined = undefined
try {
dimensions = _parseWebPDimensions(buffer)
} catch (error: any) {
logger.forBot().error('Error parsing WebP dimensions:', error?.message ?? '[unknown error]')
return undefined
}
if (!dimensions) {
logger.forBot().warn('Image is not in a valid WebP format')
return undefined
} else if (dimensions.width !== 512 || dimensions.height !== 512) {
logger
.forBot()
.warn(
`Image does not meet WhatsApp size requirements (Current ${dimensions.width}x${dimensions.height}, Expected 512x512)`
)
return undefined
}
logger.forBot().debug('Sending WhatsApp Sticker')
return new WhatsappMessages.Sticker(url, false)
}
@@ -0,0 +1,8 @@
import { Body } from 'whatsapp-api-js/messages'
import { convertMarkdownToWhatsApp } from '../../../misc/markdown-to-whatsapp-rtf'
const MAX_LENGTH = 1024
export function create(text: string) {
return new Body(convertMarkdownToWhatsApp(text).substring(0, MAX_LENGTH))
}
@@ -0,0 +1,8 @@
import { Button } from 'whatsapp-api-js/messages'
const ID_MAX_LENGTH = 256
const TITLE_MAX_LENGTH = 20
export function create({ id, title }: { id: string; title: string }) {
return new Button(id.trim().substring(0, ID_MAX_LENGTH), title.trim().substring(0, TITLE_MAX_LENGTH))
}
@@ -0,0 +1,8 @@
import { convertMarkdownToWhatsApp } from 'src/misc/markdown-to-whatsapp-rtf'
import { Footer } from 'whatsapp-api-js/messages'
const MAX_LENGTH = 60
export function create(text: string) {
return new Footer(convertMarkdownToWhatsApp(text.substring(0, MAX_LENGTH)))
}
@@ -0,0 +1,34 @@
import { ClientMessage } from 'whatsapp-api-js/types'
export type InteractivePayload = {
type: string
header?: unknown
body?: { text: string }
footer?: { text: string }
action: unknown
}
/**
* A `ClientMessage` that emits an arbitrary `interactive` payload via `toJSON()`.
*
* `whatsapp-api-js` doesn't model the December 2025 Interactive Media Carousel,
* and its `Interactive` constructor refuses image headers for `cta_url` actions
* (lib/messages/interactive.js:51-54). For both cases we build the JSON directly.
*
* The lib's `sendMessage` does `JSON.stringify(request)` after setting
* `request.interactive = this`, so a `toJSON()` override is enough — the payload
* is serialized verbatim without any of the lib's validation.
*/
export class RawInteractiveMessage extends ClientMessage {
public constructor(private readonly _payload: InteractivePayload) {
super()
}
public get _type(): 'interactive' {
return 'interactive'
}
public toJSON() {
return this._payload
}
}
+25
View File
@@ -0,0 +1,25 @@
import { posthogHelper } from '@botpress/common'
import { INTEGRATION_NAME, INTEGRATION_VERSION } from 'integration.definition'
import actions from './actions'
import channels from './channels'
import { register, unregister } from './setup'
import { handler } from './webhook'
import * as bp from '.botpress'
const integrationConfig: bp.IntegrationProps = {
register,
unregister,
actions,
channels,
handler,
}
export default posthogHelper.wrapIntegration(
{
integrationName: INTEGRATION_NAME,
key: bp.secrets.POSTHOG_KEY,
integrationVersion: INTEGRATION_VERSION,
rateLimitByFunction: { handler: 1 / 1000 },
},
integrationConfig
)
@@ -0,0 +1,7 @@
export namespace WHATSAPP {
export const API_VERSION = 'v22.0'
export const API_URL = `https://graph.facebook.com/${API_VERSION}`
export const INTERACTIVE_MAX_BUTTONS_COUNT = 3
export const BUTTON_LABEL_MAX_LENGTH = 20
}
@@ -0,0 +1,359 @@
import { describe, it, expect, test } from 'vitest'
import { convertMarkdownToWhatsApp } from './markdown-to-whatsapp-rtf'
describe('WhatsApp Markdown Converter', () => {
describe('Basic Formatting', () => {
it('should convert bold text correctly', () => {
expect(convertMarkdownToWhatsApp('**bold text**')).toBe('*bold text*')
expect(convertMarkdownToWhatsApp('__bold text__')).toBe('*bold text*')
})
it('should convert italic text correctly', () => {
expect(convertMarkdownToWhatsApp('*italic text*')).toBe('_italic text_')
expect(convertMarkdownToWhatsApp('_italic text_')).toBe('_italic text_')
})
it('should convert strikethrough text correctly', () => {
expect(convertMarkdownToWhatsApp('~~strikethrough~~')).toBe('~strikethrough~')
})
it('should leave inline code unmodified', () => {
expect(convertMarkdownToWhatsApp('`inline code`')).toBe('`inline code`')
})
it('should handle mixed formatting in same paragraph', () => {
const input = 'This is **bold** and *italic* and `code`.'
const expected = 'This is *bold* and _italic_ and `code`.'
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
it('should convert bold and italic text in bullet points', () => {
const input = '* **bold**\n* *italic*'
const expected = '- *bold*\n- _italic_'
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
})
describe('Complex Nested Formatting', () => {
it('should handle bold with italic inside', () => {
const input = '**bold _italic_ text**'
const expected = '*bold _italic_ text*'
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
it('should handle italic with bold inside', () => {
const input = '*italic **bold** text*'
const expected = '_italic *bold* text_'
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
it('should handle code with formatting inside (should preserve as-is)', () => {
const input = '`code **bold** text`'
const expected = '`code **bold** text`'
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
it('should handle strikethrough with nested formatting', () => {
const input = '~~deleted **bold** text~~'
const expected = '~deleted *bold* text~'
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
})
describe('Headers', () => {
it('should convert H1 to bold', () => {
expect(convertMarkdownToWhatsApp('# Header 1')).toBe('*Header 1*')
})
it('should convert H2 to bold', () => {
expect(convertMarkdownToWhatsApp('## Header 2')).toBe('*Header 2*')
})
it('should convert H6 to bold', () => {
expect(convertMarkdownToWhatsApp('###### Header 6')).toBe('*Header 6*')
})
it('should convert headers with underlines to bold', () => {
expect(convertMarkdownToWhatsApp('Header 1\n===')).toBe('*Header 1*')
expect(convertMarkdownToWhatsApp('Header 1\n---')).toBe('*Header 1*')
})
it('should convert headers with formatting inside (skip bold)', () => {
const input = '# Header with **bold**, *italic*, `code`, **_bolditalic_** text'
const expected = '*Header with bold, _italic_, `code`, _bolditalic_ text*'
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
})
describe('Code Blocks', () => {
it('should convert code blocks correctly', () => {
const input = '```javascript\nfunction test() {\n return true;\n}\n```'
const expected = '```function test() {\n return true;\n}```'
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
it('should handle code blocks without language specification', () => {
const input = '```\nsome code\n```'
const expected = '```some code```'
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
})
describe('Links and Images', () => {
it('should convert links to text with URL', () => {
const input = '[Google](https://google.com)'
const expected = 'Google (https://google.com)'
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
it('should convert images with alt text', () => {
const input = '![Alt text](https://example.com/image.jpg)'
const expected = 'Image: Alt text (https://example.com/image.jpg)'
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
it('should convert images without alt text', () => {
const input = '![](https://example.com/image.jpg)'
const expected = 'https://example.com/image.jpg'
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
it('should convert links with formatted text', () => {
const input = '[**Bold link**](https://example.com)'
const expected = '*Bold link* (https://example.com)'
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
it('should convert autolinks', () => {
expect(convertMarkdownToWhatsApp('<https://example.com>')).toBe('https://example.com')
})
it('should convert email autolinks', () => {
expect(convertMarkdownToWhatsApp('<test@test.com>')).toBe('test@test.com (mailto:test@test.com)')
})
it('should not convert normal email text to link', () => {
expect(convertMarkdownToWhatsApp('test@test.com')).toBe('test@test.com')
})
it('should convert links with title', () => {
const input = '[Link with title](https://example.com "Title")'
const expected = 'Link with title (https://example.com)'
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
it('should convert reference links', () => {
const input = '[Google][1]\n\n[1]: https://google.com'
const expected = 'Google (https://google.com)'
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
})
describe('Tables', () => {
it('should preserve tables as is', () => {
const input = '| Header 1 | Header 2 |\n|----------|----------|\n| Row 1 | Row 2 |'
const expected = `${input}`
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
})
describe('Lists', () => {
it('should convert unordered lists', () => {
const input = '- Item 1\n- Item 2\n- Item 3'
const expected = '- Item 1\n- Item 2\n- Item 3'
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
it('should convert ordered lists', () => {
const input = '1. First item\n2. Second item\n3. Third item'
const expected = '1. First item\n2. Second item\n3. Third item'
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
it("should convert ordered lists that don't increment indexes", () => {
const input = '1. First item\n1. Second item\n1. Third item'
const expected = '1. First item\n2. Second item\n3. Third item'
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
it('should handle nested lists', () => {
const input =
'- Item 1\n - Nested item\n- Item 2\n - Next nested item\n - Double nested item\n - Triple nested item\n - Quadruple nested item'
const expected =
'- Item 1\n\u2002\u2002◦ Nested item\n- Item 2\n\u2002\u2002◦ Next nested item\n\u2002\u2002\u2002\u2002➤ Double nested item\n\u2002\u2002\u2002\u2002\u2002\u2002✦ Triple nested item\n\u2002\u2002\u2002\u2002\u2002\u2002\u2002\u2002• Quadruple nested item'
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
it('should handle list items with formatting', () => {
const input = '- **Bold item**\n- *Italic item*\n- `Code item`'
const expected = '- *Bold item*\n- _Italic item_\n- `Code item`'
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
it('should convert task lists', () => {
const input = '- [ ] Task 1\n- [x] Task 2\n- [ ] Task 3'
const expected = '☐ Task 1\n☑ Task 2\n☐ Task 3'
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
})
describe('Blockquotes', () => {
it('should convert simple blockquotes', () => {
const input = '> This is a quote'
const expected = '> This is a quote'
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
it('should handle multi-line blockquotes', () => {
const input = '> Line 1\n> Line 2\n> Line 3'
const expected = '> Line 1\n> Line 2\n> Line 3'
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
it('should handle blockquotes with formatting', () => {
const input = '> This is **bold** in quote'
const expected = '> This is *bold* in quote'
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
it('should handle nested blockquotes', () => {
const input = '> Outer quote\n>> Inner quote'
const expected = '> Outer quote\n> » Inner quote'
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
})
describe('Horizontal Rules', () => {
it('should convert horizontal rules', () => {
expect(convertMarkdownToWhatsApp('---')).toBe('---')
expect(convertMarkdownToWhatsApp('***')).toBe('---')
expect(convertMarkdownToWhatsApp('___')).toBe('---')
})
})
describe('HTML Handling', () => {
it('should strip inline HTML tags', () => {
const input = 'Text with <strong>HTML</strong> tags'
const expected = 'Text with HTML tags'
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
it('should strip block HTML tags', () => {
const input = '<div>\nText in a div\n</div>'
const expected = 'Text in a div'
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
it('should handle HTML entities', () => {
const input = 'Text with &amp; entities'
const expected = 'Text with &amp; entities'
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
})
describe('Edge Cases', () => {
it('should handle empty input', () => {
expect(convertMarkdownToWhatsApp('')).toBe('')
})
it('should handle plain text without formatting', () => {
const input = 'Just plain text here.'
const expected = 'Just plain text here.'
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
it('should handle escaped characters', () => {
const input = 'This \\*should\\* not be bold'
const expected = 'This *should* not be bold'
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
it('should handle multiple paragraphs', () => {
const input = 'Paragraph 1\n\nParagraph 2\n\nParagraph 3'
const expected = 'Paragraph 1\n\nParagraph 2\n\nParagraph 3'
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
})
it('should clean up excessive newlines', () => {
const input = 'Text 1\n\n\n\nText 2'
const result = convertMarkdownToWhatsApp(input)
// Should not have more than 2 consecutive newlines
expect(result).not.toMatch(/\n{3,}/)
})
})
describe('Integration Tests', () => {
it('should handle complex real-world markdown', () => {
const input = `# Project README
This is a **sample project** with _various_ formatting.
## Features
- ~~Deprecated~~ New feature
- \`Code integration\`
- [Documentation](https://docs.example.com)
\`\`\`javascript
function hello() {
console.log("Hello World!");
}
\`\`\`
> Important: This is a **critical** *note*!
> > The note in question
- project a
- phase 1
- [ ] Task 1
- [x] Task 2
---
For more info, visit our ![logo](logo.png) [website](https://example.com).`
const result = convertMarkdownToWhatsApp(input)
// Check that all major elements are converted
expect(result).toContain('*Project README*')
expect(result).toContain('*Features*')
expect(result).toContain('*sample project*')
expect(result).toContain('_various_')
expect(result).toContain('~Deprecated~')
expect(result).toContain('`Code integration`')
expect(result).toContain('Documentation (https://docs.example.com)')
expect(result).toContain('```function hello()')
expect(result).toContain('> Important: This is a *critical* _note_!')
expect(result).toContain('> » The note in question')
expect(result).toContain('- project a')
expect(result).toContain('\u2002\u2002◦ phase 1')
expect(result).toContain('\u2002\u2002\u2002\u2002☐ Task 1')
expect(result).toContain('\u2002\u2002\u2002\u2002☑ Task 2')
expect(result).toContain('---')
expect(result).toContain('Image: logo (logo.png)')
expect(result).toContain('website (https://example.com)')
})
})
// Regression tests for specific bugs
describe('Regression Tests', () => {
it('should not double-convert already converted formatting', () => {
const input = '*already italic* and **already bold**'
const result = convertMarkdownToWhatsApp(input)
expect(result).toBe('_already italic_ and *already bold*')
})
it('should handle adjacent formatting correctly', () => {
const input = '**bold**_italic_`code`'
const result = convertMarkdownToWhatsApp(input)
expect(result).toBe('*bold*_italic_`code`')
})
it('should preserve whitespace in code blocks', () => {
const input = '```\n indented code\n more indent\n```'
const result = convertMarkdownToWhatsApp(input)
expect(result).toContain(' indented code')
expect(result).toContain(' more indent')
})
})
})
@@ -0,0 +1,277 @@
import { marked, Token as MarkedToken, MarkedToken as MarkedDiscriminatedToken, Tokens as MarkedTokens } from 'marked'
const supportedMarkedInlineTokenTypes = [
'strong',
'em',
'def',
'del',
'codespan',
'link',
'image',
'text',
'escape',
'br',
] as const
const supportedMarkdedBlockTokenTypes = [
'paragraph',
'heading',
'list',
'list_item',
'code',
'hr',
'text',
'space',
'html',
'blockquote',
'table',
] as const
const supportedMarkedTokenTypes = [...supportedMarkedInlineTokenTypes, ...supportedMarkdedBlockTokenTypes] as const
type SupportedMarkedTokenTypes = (typeof supportedMarkedTokenTypes)[number]
type SupportedMarkedToken = Extract<MarkedToken, { type: SupportedMarkedTokenTypes }>
type GenericToken = MarkedTokens.Generic & {
type: 'generic' // Use 'generic' for unsupported types to allow discrimination
originalType: string // Store the original type for reference
}
type Token = SupportedMarkedToken | GenericToken
// Ensure token type literals are valid
type AssertExtends<_A extends B, B> = true
type _assertion = AssertExtends<SupportedMarkedTokenTypes, MarkedDiscriminatedToken['type']>
type RequireSome<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>
type Context = {
currentListDepth?: number
currentQuoteDepth?: number
inHeader?: boolean
}
const FIXED_SIZE_SPACE_CHAR = '\u2002' // 'En space' yields better results for identation in WhatsApp messages
const ALT_BULLET_SYMBOLS = ['•', '◦', '➤', '✦'] // WhatsApp doesn't support nested lists
/**
* Converts standard markdown to WhatsApp-compatible formatting using AST parsing (See [reference](https://faq.whatsapp.com/539178204879377/?cms_platform=web&locale=en_US))
*
* @param markdown - Input string with standard markdown formatting
* @returns String formatted for WhatsApp
*/
export function convertMarkdownToWhatsApp(markdown: string): string {
const tokens = _convertMarkedTokensToTokens(marked.lexer(markdown))
return _processTokens(tokens, {}).trimEnd()
}
function _processTokens(tokens: Token[], ctx: Context): string {
return tokens.map((token) => _processToken(token, ctx)).join('')
}
function _processToken(token: Token, ctx: Context): string {
switch (token.type) {
case 'paragraph':
return _processInlineTokens(_convertMarkedTokensToTokens(token.tokens), ctx) + '\n'
case 'heading':
// Convert headers to bold text
const headingText = _processInlineTokens(_convertMarkedTokensToTokens(token.tokens), { ...ctx, inHeader: true })
return `*${headingText}*\n\n`
case 'blockquote':
return (
_processBlockQuote(token, {
...ctx,
currentQuoteDepth: ctx.currentQuoteDepth ?? 0,
}) + '\n'
)
case 'list':
return (
_processListToken(token, {
...ctx,
currentListDepth: ctx.currentListDepth ?? 0,
}) + '\n'
)
case 'list_item':
return _processTokens(_convertMarkedTokensToTokens(token.tokens), ctx)
case 'code':
// Block code
return `\`\`\`${token.text}\`\`\`\n`
case 'hr':
return '---\n'
case 'text':
return _processTextToken(token, ctx) + '\n'
case 'space':
return '\n'
case 'html':
// Strip HTML tags
return _removeEmptyLinesFromText(token.text.replace(/<[^>]*>/g, '')) + '\n\n'
case 'table':
return token.raw
default:
// Handle any other token types by processing their tokens if they exist
if ('tokens' in token && token.tokens) {
// No support for nested unknown tokens, processing as inline
_processInlineTokens(_convertMarkedTokensToTokens(token.tokens), ctx)
}
if ('text' in token) {
return token.text
}
return ''
}
}
function _processInlineTokens(tokens: Token[], ctx: Context): string {
return tokens.map((token) => _processInlineToken(token, ctx)).join('')
}
function _processInlineToken(token: Token, ctx: Context): string {
switch (token.type) {
case 'strong':
// Bold: **text** or __text__ -> *text*
const boldText = _processInlineTokens(_convertMarkedTokensToTokens(token.tokens), ctx)
return ctx.inHeader ? boldText : `*${boldText}*`
case 'em':
// Italic: *text* or _text_ -> _text_
const italicText = _processInlineTokens(_convertMarkedTokensToTokens(token.tokens), ctx)
return `_${italicText}_`
case 'def':
// Definition: [text]: url -> text (url)
return `${token.title} (${token.href})`
case 'del':
// Strikethrough: ~~text~~ -> ~text~
const strikeText = _processInlineTokens(_convertMarkedTokensToTokens(token.tokens), ctx)
return `~${strikeText}~`
case 'codespan':
// Inline code: `text` -> `text`
return `\`${token.text}\``
case 'link':
// Links: [text](url) -> text (url)
const linkText = _processInlineTokens(_convertMarkedTokensToTokens(token.tokens), ctx)
const isTrueAutolink = /^<.*>$/.test(token.raw)
const isEmail = token.href.startsWith('mailto:')
if (isEmail && !isTrueAutolink) {
return linkText
}
return linkText !== token.href ? `${linkText} (${token.href})` : token.href
case 'image':
// Images: ![alt](url) -> Image: alt (url) or just url
const altText = token.title || token.text
return altText ? `Image: ${altText} (${token.href})` : token.href
case 'text':
return _processTextToken(token, ctx)
case 'html':
// Strip HTML tags
return token.text.replace(/<[^>]*>/g, '')
case 'escape':
return token.text
case 'br':
return '\n'
default:
// Handle nested tokens
if ('tokens' in token && token.tokens) {
return _processInlineTokens(_convertMarkedTokensToTokens(token.tokens), ctx)
}
if ('text' in token) {
return token.text
}
return ''
}
}
function _processListToken(token: Token & { type: 'list' }, ctx: RequireSome<Context, 'currentListDepth'>): string {
const items = token.items.map((item, index) => {
const itemText = _processToken(item, { ...ctx, currentListDepth: ctx.currentListDepth + 1 })
let prefix
if (token.ordered) {
prefix = `${index + 1}. `
} else if (item.task) {
prefix = item.checked ? '☑ ' : '☐ '
} else {
const symbol = ctx.currentListDepth ? ALT_BULLET_SYMBOLS[ctx.currentListDepth % ALT_BULLET_SYMBOLS.length] : '-'
prefix = symbol + ' '
}
return prefix + itemText
})
let itemsText = items
.map((item) => item.split('\n'))
.flat()
.filter(_isNonEmptyLine)
.map((line) => `${ctx.currentListDepth ? FIXED_SIZE_SPACE_CHAR.repeat(2) : ''}${line}`)
.join('\n')
if (ctx.currentListDepth) {
// Nested lists should start on next line
itemsText = `\n${itemsText}`
}
return itemsText
}
function _processBlockQuote(
token: Token & { type: 'blockquote' },
ctx: RequireSome<Context, 'currentQuoteDepth'>
): string {
const prefix = ctx.currentQuoteDepth ? '» ' : '> ' // WhatsApp doesn't support nested blockquotes
const tokens = _convertMarkedTokensToTokens(token.tokens)
return tokens
.map((t) => _processToken(t, { ...ctx, currentQuoteDepth: ctx.currentQuoteDepth + 1 }))
.map((text) => text.split('\n'))
.flat()
.filter(_isNonEmptyLine)
.map((line) => prefix + line)
.join('\n')
}
function _processTextToken(token: Token & { type: 'text' }, ctx: Context): string {
// Process text tokens, which may contain inline formatting
if (token.tokens) {
return _processInlineTokens(_convertMarkedTokensToTokens(token.tokens), ctx)
}
return token.text
}
function _isSupportedMarkedToken(token: MarkedToken): token is SupportedMarkedToken {
return (supportedMarkedTokenTypes as unknown as string[]).includes(token.type)
}
function _convertMarkedGenericTokenToGenericToken(token: MarkedTokens.Generic): GenericToken {
return {
...token,
type: 'generic',
originalType: token.type,
}
}
function _convertMarkedTokenToToken(token: MarkedToken): Token {
if (_isSupportedMarkedToken(token)) {
return token
}
return _convertMarkedGenericTokenToGenericToken(token)
}
function _convertMarkedTokensToTokens(tokens: MarkedToken[]): Token[] {
return tokens.map(_convertMarkedTokenToToken)
}
function _isNonEmptyLine(line: string): boolean {
return line.trim() !== ''
}
function _removeEmptyLinesFromText(text: string): string {
return text.split('\n').filter(_isNonEmptyLine).join('\n')
}
@@ -0,0 +1,175 @@
import { test, expect } from 'vitest'
import { formatPhoneNumber } from './phone-number-to-whatsapp'
test('throws on invalid text', () => {
expect(() => formatPhoneNumber('garbage')).toThrow('Invalid phone number')
})
test('throws on invalid phone number', () => {
expect(() => formatPhoneNumber('1')).toThrow('Invalid phone number')
})
test('Argentina: removes "15" prefix, trims leading zeros, and inserts +549 (mobile)', () => {
const input = '+54 (11) 15 2345-6789'
const actual = formatPhoneNumber(input)
const expected = '+5491123456789'
expect(actual).toBe(expected)
})
test('Argentina: also strips national leading zeros even without "15" (fixed-line)', () => {
const input = '+54 (011) 2345 6789'
const actual = formatPhoneNumber(input)
const expected = '+5491123456789'
expect(actual).toBe(expected)
})
test('Mexico: ensures +521 prefix', () => {
const input = '+52 55 1234 5678'
const actual = formatPhoneNumber(input)
const expected = '+5215512345678'
expect(actual).toBe(expected)
})
test('Mexico: +521 already entered correctly', () => {
const input = '+5211234567890'
const actual = formatPhoneNumber(input)
const expected = '+5211234567890'
expect(actual).toBe(expected)
})
test('Generic leading-zero cleanup for countries other than AR/MX', () => {
const input = '+44 (0151 12) 34 567'
const actual = formatPhoneNumber(input)
const expected = '+441511234567'
expect(actual).toBe(expected)
})
test('Ensure this phone number typing works +1 (xxx) xxx-xxxx', () => {
const input = '+1 (581) 849-1511'
const actual = formatPhoneNumber(input)
const expected = '+15818491511'
expect(actual).toBe(expected)
})
test('Ensure this phone number typing works +1 xxx xxx-xxxx', () => {
const input = '+1 581 849-1511'
const actual = formatPhoneNumber(input)
const expected = '+15818491511'
expect(actual).toBe(expected)
})
test('Ensure this phone number typing works +1 xxx xxx xxxx', () => {
const input = '+1 581 849 1511'
const actual = formatPhoneNumber(input)
const expected = '+15818491511'
expect(actual).toBe(expected)
})
test('Ensure this phone number typing works +1xxxxxxxxxx', () => {
const input = '+15818491511'
const actual = formatPhoneNumber(input)
const expected = '+15818491511'
expect(actual).toBe(expected)
expect(formatPhoneNumber('+15818491511')).toBe('+15818491511')
})
test('Ensure this phone number typing works 1 (xxx) xxx-xxxx', () => {
const input = '1 (581) 849-1511'
const actual = formatPhoneNumber(input)
const expected = '+15818491511'
expect(actual).toBe(expected)
})
test('Ensure this phone number typing works 1 xxx xxx-xxxx', () => {
const input = '1 581 849-1511'
const actual = formatPhoneNumber(input)
const expected = '+15818491511'
expect(actual).toBe(expected)
})
test('Ensure this phone number typing works 1 xxx xxx xxxx', () => {
const input = '1 581 849 1511'
const actual = formatPhoneNumber(input)
const expected = '+15818491511'
expect(actual).toBe(expected)
})
test('Ensure this phone number typing works 1xxxxxxxxxx', () => {
const input = '15818491511'
const actual = formatPhoneNumber(input)
const expected = '+15818491511'
expect(actual).toBe(expected)
})
// Automatically generated examples for all countries
const samples: Record<string, string> = {
AE: '971501234567',
AT: '436601234567',
AU: '61412345678',
BD: '8801712345678',
BE: '32470123456',
BG: '359881234567',
BR: '5521969853304',
CA: '14165551234',
CH: '41791234567',
CL: '56947590871',
CN: '8613812345678',
CO: '573001234567',
CR: '50670123456',
CZ: '420601234567',
DE: '4915123456789',
DK: '4520123456',
DO: '18299565525',
EG: '201001234567',
ES: '34600123456',
FI: '358401234567',
FR: '33632467939',
GB: '447911123456',
GR: '306912345678',
HK: '85291234567',
HR: '385981234567',
HU: '36301234567',
ID: '628123456789',
IE: '353871234567',
IL: '97233741654',
IN: '919876543210',
IR: '989121234567',
IS: '3546123456',
IT: '393331234567',
JM: '18762091234',
JP: '819012345678',
KE: '254701234567',
KR: '821012345678',
LK: '94711234567',
MY: '60123456789',
NG: '2348012345678',
NL: '31612345678',
NO: '4791234567',
NZ: '64211234567',
PE: '51984123456',
PH: '639171234567',
PK: '923001234567',
PL: '48501234567',
PT: '351912345678',
RO: '40721234567',
RU: '79161234567',
SA: '966501234567',
SE: '46701234567',
SG: '6591234567',
SI: '38640123456',
SK: '421901234567',
TH: '66812345678',
TR: '905312345678',
TW: '886912345678',
UA: '380501234567',
US: '14155552671',
VE: '584121234567',
VN: '84901234567',
ZA: '27821234567',
}
test.each(Object.entries(samples))('Test %s', (_countryCode, num) => {
const actual = formatPhoneNumber(num)
const expected = `+${num}`
expect(actual).toBe(expected)
})
@@ -0,0 +1,103 @@
import { ApiError, isApiError, RuntimeError } from '@botpress/client'
import { parsePhoneNumber, ParsedPhoneNumber } from 'awesome-phonenumber'
const ARGENTINA_COUNTRY_CODE = 54
const ARGENTINA_COUNTRY_CODE_AFTER_PREFIX = 9
const MEXICO_COUNTRY_CODE = 52
const MEXICO_COUNTRY_CODE_AFTER_PREFIX = 1
type formatPhoneNumberResponse =
| {
success: true
phoneNumber: string
}
| {
success: false
error: ApiError
}
export function safeFormatPhoneNumber(phoneNumber: string): formatPhoneNumberResponse {
try {
const formattedPhoneNumber = formatPhoneNumber(phoneNumber)
return {
success: true,
phoneNumber: formattedPhoneNumber,
}
} catch (thrown) {
const error = isApiError(thrown) ? thrown : new RuntimeError(String(thrown))
return {
success: false,
error,
}
}
}
export function formatPhoneNumber(phoneNumber: string) {
if (!phoneNumber.startsWith('+')) {
// TODO log to use international phone number
phoneNumber = `+${phoneNumber}`
}
const parsed = parsePhoneNumber(phoneNumber)
if (parsed.possibility === 'invalid-country-code' || !parsed.number) {
throw new RuntimeError('Invalid phone number, try adding the country code (e.g. +81 for Japan)')
}
let phone = parsed.number.e164
phone = _handleWhatsAppEdgeCases(phone, parsed)
return phone
}
function _handleWhatsAppEdgeCases(phone: string, parsed: ParsedPhoneNumber): string {
if (!parsed.countryCode) {
return phone
}
if (parsed.countryCode === ARGENTINA_COUNTRY_CODE) {
phone = _handleArgentinaEdgeCases(phone, parsed.countryCode)
} else if (parsed.countryCode === MEXICO_COUNTRY_CODE) {
phone = _handleMexicoEdgeCases(parsed)
} else {
let nationalNumber = _stripCountryCode(phone, parsed.countryCode)
nationalNumber = _stripLeadingZeros(nationalNumber)
phone = `+${parsed.countryCode}${nationalNumber}`
}
return phone
}
// This needs to remove leading zeros and make sure the number starts with +549
const _handleArgentinaEdgeCases = (phone: string, countryCode: number): string => {
let nationalNumber = _stripCountryCode(phone, countryCode)
nationalNumber = _stripLeadingZeros(nationalNumber)
if (nationalNumber.startsWith(ARGENTINA_COUNTRY_CODE_AFTER_PREFIX.toString())) {
return `+${ARGENTINA_COUNTRY_CODE}${nationalNumber}`
}
return `+${ARGENTINA_COUNTRY_CODE}${ARGENTINA_COUNTRY_CODE_AFTER_PREFIX}${nationalNumber}`
}
// This needs to remove leading zeros and make sure the number starts with +521
// Note: Mexico phone number should never have leading zeros to begin with since they follow the E.164 recommendation
const _handleMexicoEdgeCases = (parsedPhoneNumber: ParsedPhoneNumber): string => {
if (!parsedPhoneNumber.number || !parsedPhoneNumber.countryCode) {
throw new RuntimeError('Invalid phone number, try adding the country code (e.g. +52 55 1234 5678)')
}
let nationalNumber = _stripCountryCode(parsedPhoneNumber.number.e164, parsedPhoneNumber.countryCode)
nationalNumber = _stripLeadingZeros(nationalNumber)
if (parsedPhoneNumber.possibility === 'too-long') {
nationalNumber = nationalNumber?.slice(1, nationalNumber.length)
}
return `+${MEXICO_COUNTRY_CODE}${MEXICO_COUNTRY_CODE_AFTER_PREFIX}${nationalNumber}`
}
const _stripLeadingZeros = (phone: string): string => {
return phone.replace(/^0+/, '')
}
const _stripCountryCode = (phone: string, countryCode: number): string => {
if (phone.startsWith('+')) {
phone = phone.slice('+'.length)
}
return phone.slice(countryCode.toString().length)
}
@@ -0,0 +1,144 @@
import { describe, it, expect } from 'vitest'
import { splitTextMessageIfNeeded } from './split-text-message'
const WHATSAPP_MAX_TEXT_LENGTH = 4096
describe('splitTextMessageIfNeeded', () => {
describe('Messages within limit', () => {
it('should return a single string for empty string', () => {
const result = splitTextMessageIfNeeded('')
expect(result).toHaveLength(1)
expect(result[0]).toBe('')
expect(typeof result[0]).toBe('string')
})
it('should return a single string for short message', () => {
const message = 'Hello, world!'
const result = splitTextMessageIfNeeded(message)
expect(result).toHaveLength(1)
expect(typeof result[0]).toBe('string')
expect(result[0]).toBe(message)
})
it('should return a single string for message at exact limit', () => {
const message = 'a'.repeat(WHATSAPP_MAX_TEXT_LENGTH)
const result = splitTextMessageIfNeeded(message)
expect(result).toHaveLength(1)
expect(typeof result[0]).toBe('string')
expect(result[0]).toBe(message)
expect(result[0]!.length).toBe(WHATSAPP_MAX_TEXT_LENGTH)
})
})
describe('Messages exceeding limit', () => {
it('should split message that is one character over limit', () => {
const message = 'a'.repeat(WHATSAPP_MAX_TEXT_LENGTH + 1)
const result = splitTextMessageIfNeeded(message)
expect(result).toHaveLength(2)
expect(typeof result[0]).toBe('string')
expect(typeof result[1]).toBe('string')
expect(result[0]!.length).toBe(WHATSAPP_MAX_TEXT_LENGTH)
expect(result[1]!.length).toBe(1)
expect(result[0]! + result[1]!).toBe(message)
})
it('should split message that is exactly double the limit', () => {
const message = 'a'.repeat(WHATSAPP_MAX_TEXT_LENGTH * 2)
const result = splitTextMessageIfNeeded(message)
expect(result).toHaveLength(2)
expect(typeof result[0]).toBe('string')
expect(typeof result[1]).toBe('string')
expect(result[0]!.length).toBe(WHATSAPP_MAX_TEXT_LENGTH)
expect(result[1]!.length).toBe(WHATSAPP_MAX_TEXT_LENGTH)
expect(result[0]! + result[1]!).toBe(message)
})
it('should split message that requires multiple chunks', () => {
const message = 'a'.repeat(WHATSAPP_MAX_TEXT_LENGTH * 3 + 100)
const result = splitTextMessageIfNeeded(message)
expect(result).toHaveLength(4)
for (const chunk of result) {
expect(typeof chunk).toBe('string')
}
expect(result[0]!.length).toBe(WHATSAPP_MAX_TEXT_LENGTH)
expect(result[1]!.length).toBe(WHATSAPP_MAX_TEXT_LENGTH)
expect(result[2]!.length).toBe(WHATSAPP_MAX_TEXT_LENGTH)
expect(result[3]!.length).toBe(100)
expect(result.join('')).toBe(message)
})
it('should split large message correctly', () => {
const message = 'b'.repeat(10000)
const result = splitTextMessageIfNeeded(message)
const expectedChunks = Math.ceil(10000 / WHATSAPP_MAX_TEXT_LENGTH)
expect(result).toHaveLength(expectedChunks)
for (const chunk of result) {
expect(typeof chunk).toBe('string')
expect(chunk.length).toBeLessThanOrEqual(WHATSAPP_MAX_TEXT_LENGTH)
}
expect(result.join('')).toBe(message)
})
})
describe('Message content preservation', () => {
it('should preserve message content exactly when splitting', () => {
const message = 'Hello, this is a test message! ' + 'x'.repeat(WHATSAPP_MAX_TEXT_LENGTH)
const result = splitTextMessageIfNeeded(message)
const reconstructed = result.join('')
expect(reconstructed).toBe(message)
})
it('should handle unicode characters correctly', () => {
const message = 'Hello 世界 🌍 ' + 'é'.repeat(WHATSAPP_MAX_TEXT_LENGTH)
const result = splitTextMessageIfNeeded(message)
const reconstructed = result.join('')
expect(reconstructed).toBe(message)
})
it('should handle newlines and special characters', () => {
const baseMessage = 'Line 1\nLine 2\nLine 3\n' + 'Special: !@#$%^&*()'
const message = baseMessage + 'x'.repeat(WHATSAPP_MAX_TEXT_LENGTH)
const result = splitTextMessageIfNeeded(message)
const reconstructed = result.join('')
expect(reconstructed).toBe(message)
})
})
describe('Edge cases', () => {
it('should handle message that is exactly limit minus one', () => {
const message = 'a'.repeat(WHATSAPP_MAX_TEXT_LENGTH - 1)
const result = splitTextMessageIfNeeded(message)
expect(result).toHaveLength(1)
expect(result[0]!.length).toBe(WHATSAPP_MAX_TEXT_LENGTH - 1)
})
it('should handle message that is exactly limit plus one', () => {
const message = 'a'.repeat(WHATSAPP_MAX_TEXT_LENGTH + 1)
const result = splitTextMessageIfNeeded(message)
expect(result).toHaveLength(2)
expect(result[0]!.length).toBe(WHATSAPP_MAX_TEXT_LENGTH)
expect(result[1]!.length).toBe(1)
})
it('should handle very large message (10x limit)', () => {
const message = 'c'.repeat(WHATSAPP_MAX_TEXT_LENGTH * 10)
const result = splitTextMessageIfNeeded(message)
expect(result).toHaveLength(10)
for (const chunk of result) {
expect(typeof chunk).toBe('string')
expect(chunk.length).toBe(WHATSAPP_MAX_TEXT_LENGTH)
}
expect(result.join('')).toBe(message)
})
it('should handle message with exactly 2x limit plus one', () => {
const message = 'd'.repeat(WHATSAPP_MAX_TEXT_LENGTH * 2 + 1)
const result = splitTextMessageIfNeeded(message)
expect(result).toHaveLength(3)
expect(result[0]!.length).toBe(WHATSAPP_MAX_TEXT_LENGTH)
expect(result[1]!.length).toBe(WHATSAPP_MAX_TEXT_LENGTH)
expect(result[2]!.length).toBe(1)
expect(result.join('')).toBe(message)
})
})
})
@@ -0,0 +1,17 @@
const WHATSAPP_MAX_TEXT_LENGTH = 4096
export function splitTextMessageIfNeeded(message: string): string[] {
const textLength = message.length
if (textLength <= WHATSAPP_MAX_TEXT_LENGTH) {
return [message]
}
const numChunks = Math.ceil(textLength / WHATSAPP_MAX_TEXT_LENGTH)
const chunks = Array.from({ length: numChunks }, (_, i) => {
const start = i * WHATSAPP_MAX_TEXT_LENGTH
const end = (i + 1) * WHATSAPP_MAX_TEXT_LENGTH
return message.slice(start, end)
})
return chunks
}
@@ -0,0 +1,430 @@
import { z } from '@botpress/sdk'
import axios from 'axios'
import {
Component,
KeyValuePair,
NamedVariables,
TemplateBodyParams,
TemplateButtonParam,
TemplateHeaderParam,
TemplateVariables,
templateVariablesSchema,
} from 'src/misc/types'
import {
BodyComponent,
BodyParameter,
HeaderComponent,
HeaderParameter,
URLComponent,
PayloadComponent,
CopyComponent,
SkipButtonComponent,
Image,
Document,
Video,
} from 'whatsapp-api-js/messages'
import type { TemplateComponent } from 'whatsapp-api-js/types'
import { getAccessToken, getWabaId } from '../auth'
import { WHATSAPP } from './constants'
import { hasAtleastOne, logForBotAndThrow } from './util'
import * as bp from '.botpress'
const whatsAppButtonSchema = z.object({
type: z.string(),
text: z.string().optional(),
url: z.string().optional(),
phone_number: z.string().optional(),
})
const whatsAppComponentSchema = z.object({
type: z.string(),
format: z.string().optional(),
text: z.string().optional(),
buttons: z.array(whatsAppButtonSchema).optional(),
example: z.record(z.unknown()).optional(),
})
const whatsAppTemplateSchema = z.object({
id: z.string(),
name: z.string(),
status: z.string(),
category: z.string(),
language: z.string(),
components: z.array(whatsAppComponentSchema).optional(),
})
const whatsAppTemplatesResponseSchema = z.object({
data: z.array(whatsAppTemplateSchema),
paging: z
.object({
cursors: z
.object({
after: z.string().optional(),
})
.optional(),
})
.optional(),
})
const fetchTemplatesParamsSchema = z.object({
name: z.string().optional(),
language: z.string().optional(),
status: z.string().optional(),
limit: z.number().optional(),
after: z.string().optional(),
fields: z.string().optional(),
})
export type WhatsAppButton = z.infer<typeof whatsAppButtonSchema>
export type WhatsAppComponent = z.infer<typeof whatsAppComponentSchema>
export type WhatsAppTemplate = z.infer<typeof whatsAppTemplateSchema>
export type WhatsAppTemplatesResponse = z.infer<typeof whatsAppTemplatesResponseSchema>
export type FetchTemplatesParams = z.infer<typeof fetchTemplatesParamsSchema>
export const fetchTemplates = async (
ctx: bp.Context,
client: bp.Client,
logger: bp.Logger,
params: FetchTemplatesParams = {}
): Promise<WhatsAppTemplatesResponse> => {
const accessToken = await getAccessToken(client, ctx)
const wabaId = await getWabaId(client, ctx)
const queryParams = new URLSearchParams()
if (params.fields) {
queryParams.append('fields', params.fields)
}
if (params.name) {
queryParams.append('name', params.name)
}
if (params.language) {
queryParams.append('language', params.language)
}
if (params.status) {
queryParams.append('status', params.status)
}
if (params.limit) {
queryParams.append('limit', String(params.limit))
}
if (params.after) {
queryParams.append('after', params.after)
}
const url = `${WHATSAPP.API_URL}/${wabaId}/message_templates?${queryParams.toString()}`
const response = await axios
.get<WhatsAppTemplatesResponse>(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
.catch((e) => {
logForBotAndThrow(`Failed to fetch message templates: ${e.response?.data?.error?.message ?? e.message}`, logger)
})
const parsedResult = whatsAppTemplatesResponseSchema.safeParse(response.data)
if (!parsedResult.success) {
logForBotAndThrow(
`Failed to parse response from WhatsApp API when fetching templates: ${parsedResult.error.message}`,
logger
)
}
return parsedResult.data
}
// TODO: Remove in the next major
export function parseTemplateVariablesJSON(templateVariablesJson: string, logger: bp.Logger): TemplateVariables {
let templateVariablesRaw: unknown
try {
templateVariablesRaw = JSON.parse(templateVariablesJson)
} catch (err: unknown) {
const message = err instanceof Error ? err.message : ''
logForBotAndThrow(
`Value provided for Template Variables JSON isn't valid JSON (error: ${message}). Received: ${templateVariablesJson}`,
logger
)
}
const validationResult = templateVariablesSchema.safeParse(templateVariablesRaw)
if (!validationResult.success) {
logForBotAndThrow(
`Template variables should be an array or an object of strings/numbers (error: ${validationResult.error})`,
logger
)
}
return validationResult.data
}
function isNamedVariables(variables: TemplateVariables): variables is NamedVariables {
return !Array.isArray(variables)
}
function headerParamToKVPairs(param: TemplateHeaderParam | undefined): KeyValuePair[] {
if (!param) {
return []
}
switch (param.type) {
case 'text':
return [{ key: param.parameterName ?? '1', value: param.value }]
case 'image':
return [{ key: 'image', value: param.url }]
case 'video':
return [{ key: 'video', value: param.url }]
case 'document':
return [{ key: param.filename ? `document:${param.filename}` : 'document', value: param.url }]
}
}
function bodyParamsToKVPairs(params: TemplateBodyParams | undefined): KeyValuePair[] {
if (!params) {
return []
}
switch (params.type) {
case 'positional':
return params.values.map((v, i) => ({ key: String(i + 1), value: v }))
case 'named':
return Object.entries(params.values).map(([key, value]) => ({ key, value }))
}
}
export const generateSyntheticTemplateText = async (
ctx: bp.Context,
client: bp.Client,
logger: bp.Logger,
templateName: string,
templateLanguage: string,
bodyParams: TemplateBodyParams | undefined,
headerParam: TemplateHeaderParam | undefined
): Promise<string> => {
try {
const response = await fetchTemplates(ctx, client, logger, {
name: templateName,
language: templateLanguage,
})
const template = response.data[0]
if (!template) {
throw new Error('No template received')
}
const bodyKV = bodyParamsToKVPairs(bodyParams)
const headerKV = headerParamToKVPairs(headerParam)
return _getTemplateText((template.components ?? []) as Component[], bodyKV, headerKV)
} catch (thrown) {
const errMsg = thrown instanceof Error ? thrown.message : String(thrown)
logger.forBot().debug(`failed to get template text - ${errMsg}`)
return `Sent template "${templateName}" with language "${templateLanguage}"`
}
}
const _getTemplateText = (
templateComponents: Component[],
bodyParams: KeyValuePair[],
headerParams: KeyValuePair[]
) => {
let templateText = ''
for (const component of templateComponents) {
const componentText = _parseComponent(component, bodyParams, headerParams)
if (!componentText) {
throw new Error('componentText is undefined')
}
templateText += componentText
}
return templateText
}
const _parseComponent = (
component: Component,
bodyParams: KeyValuePair[],
headerParams: KeyValuePair[]
): string | undefined => {
let compText
switch (component.type) {
case 'BODY':
compText = component.text ?? 'body has no text'
return `[BODY]\n${_getRenderedBodyText(compText, bodyParams)}\n`
case 'HEADER':
if (headerParams.length > 0) {
const first = headerParams[0]!
const baseKey = first.key.split(':')[0]!
if (baseKey === 'image') {
return `[HEADER MEDIA IMAGE]\n${first.value}\n`
}
if (baseKey === 'video') {
return `[HEADER MEDIA VIDEO]\n${first.value}\n`
}
if (baseKey === 'document') {
return `[HEADER MEDIA DOCUMENT]\n${first.value}\n`
}
// Text header
let headerText = first.value
if (component.format === 'TEXT' && component.text) {
headerText = component.text.replace(/\{\{[^}]+\}\}/, first.value)
}
return `[HEADER TEXT]\n${headerText}\n`
}
if (!component.format) {
compText = component.parameters.flatMap((parameter) => {
return `lat: ${parameter.location.latitude} long: ${parameter.location.longitude} address: ${parameter.location.address} name: ${parameter.location.name}\n`
})
return `[HEADER LOCATION]\n${compText}`
} else if (component.format === 'TEXT') {
compText = component.text ?? 'header has no text'
return `[HEADER TEXT]\n${component.text}\n`
} else {
compText = component.example.header_handle.flatMap((url: string) => `${url}\n`)
return `[HEADER MEDIA ${component.format}]\n${compText}\n`
}
case 'BUTTONS':
compText = component.buttons.flatMap((button: { text?: string }, index: number) => {
if (!button.text) {
return
}
return `button #${index + 1}: ${button.text}\n`
})
return `[buttons]\n${compText}`
case 'FOOTER':
compText = component.text ?? 'footer has no text'
return `[FOOTER]\n${compText}\n`
case 'FLOW':
compText = component.text ?? 'flow has no text'
return `[FLOW]\n${compText}\n`
case 'QUICK_REPLY':
compText = component.text ?? 'quick reply has no text'
return `[QUICK_REPLY]\n${compText}\n`
case 'PHONE_NUMBER':
compText = component.text ?? 'phone number has no text'
return `[PHONE_NUMBER]\n${compText}\n`
case 'URL':
compText = component.text ?? 'url has no text'
return `[URL]\n${compText}\n`
default:
return undefined
}
}
const _getRenderedBodyText = (text: string, bodyParams: KeyValuePair[]): string => {
for (const { key, value } of bodyParams) {
text = text.replace(`{{${key}}}`, value)
}
return text
}
/**
* Custom body component that supports named parameter_name fields,
* which the whatsapp-api-js library does not yet support.
*/
class NamedBodyComponent implements TemplateComponent {
public readonly type = 'body' as const
public readonly parameters: Array<{ type: 'text'; parameter_name: string; text: string }>
public constructor(namedValues: Record<string, string>) {
this.parameters = Object.entries(namedValues).map(([key, value]) => ({
type: 'text' as const,
parameter_name: key,
text: value,
}))
}
public _build() {
return this
}
}
/**
* Custom header component that supports the named parameter_name field,
* which the whatsapp-api-js library does not yet support.
*/
class NamedHeaderComponent implements TemplateComponent {
public readonly type = 'header' as const
public readonly parameters: Array<{ type: 'text'; parameter_name: string; text: string }>
public constructor(parameterName: string, value: string) {
this.parameters = [
{
type: 'text' as const,
parameter_name: parameterName,
text: value,
},
]
}
public _build() {
return this
}
}
export function buildHeaderComponent(param: TemplateHeaderParam): TemplateComponent {
switch (param.type) {
case 'image':
return new HeaderComponent(new HeaderParameter(new Image(param.url)))
case 'video':
return new HeaderComponent(new HeaderParameter(new Video(param.url)))
case 'document':
return new HeaderComponent(new HeaderParameter(new Document(param.url, false, undefined, param.filename)))
case 'text':
if (param.parameterName) {
return new NamedHeaderComponent(param.parameterName, param.value)
}
return new HeaderComponent(new HeaderParameter(param.value))
}
}
export function buildBodyComponent(params: TemplateBodyParams): TemplateComponent | undefined {
switch (params.type) {
case 'positional': {
if (params.values.length === 0) {
return undefined
}
const bodyParams = params.values.map((v) => new BodyParameter(v))
if (hasAtleastOne(bodyParams)) {
return new BodyComponent(...bodyParams)
}
return undefined
}
case 'named': {
if (Object.keys(params.values).length === 0) {
return undefined
}
return new NamedBodyComponent(params.values)
}
}
}
export function buildBodyComponentFromLegacy(variables: TemplateVariables): TemplateComponent | undefined {
if (isNamedVariables(variables)) {
if (Object.keys(variables).length === 0) {
return undefined
}
const stringValues: Record<string, string> = {}
for (const [key, value] of Object.entries(variables)) {
stringValues[key] = value.toString()
}
return new NamedBodyComponent(stringValues)
}
const bodyParams: BodyParameter[] = variables.map((v) => new BodyParameter(v.toString()))
if (hasAtleastOne(bodyParams)) {
return new BodyComponent(...bodyParams)
}
return undefined
}
export function buildButtonComponents(params: TemplateButtonParam[]): TemplateComponent[] {
return params.flatMap((param): TemplateComponent[] => {
switch (param.type) {
case 'url':
return [new URLComponent(param.value)]
case 'quick_reply':
return [new PayloadComponent(param.payload)]
case 'copy_code':
return [new CopyComponent(param.code)]
case 'skip':
return [new SkipButtonComponent()]
}
})
}
+432
View File
@@ -0,0 +1,432 @@
import { z } from '@botpress/sdk'
import { qualityScoreSchema } from 'definitions/events'
const WhatsAppContactSchema = z.object({
wa_id: z.string().optional(),
user_id: z.string().optional(),
profile: z
.object({
name: z.string().optional(),
username: z.string().optional(),
})
.optional(),
})
const WhatsAppBaseMessageSchema = z.object({
from: z.string().optional(),
from_user_id: z.string().optional(),
id: z.string(),
timestamp: z.string(),
type: z.string(),
context: z
.object({
forwarded: z.boolean().optional(),
frequently_forwarded: z.boolean().optional(),
from: z.string().optional(),
id: z.string().optional(),
})
.optional(),
// there are other fields in the referral object, but we don't need them
referral: z
.object({
source_url: z.string().optional(),
source_id: z.string().optional(),
})
.optional(),
errors: z
.array(
z.object({
code: z.number(),
title: z.string(),
message: z.string(),
error_data: z.object({
details: z.string(),
}),
})
)
.optional(),
})
const WhatsAppMessageInteractiveSchema = z.union([
z.object({
type: z.literal('button_reply'),
button_reply: z.object({
id: z.string(),
title: z.string(),
}),
}),
z.object({
type: z.literal('list_reply'),
list_reply: z.object({
id: z.string(),
title: z.string(),
description: z.string(),
}),
}),
])
const WhatsAppMessageSchema = z.union([
WhatsAppBaseMessageSchema.extend({
type: z.literal('text'),
text: z.object({
body: z.string(),
}),
}),
WhatsAppBaseMessageSchema.extend({
type: z.literal('image'),
image: z.object({
caption: z.string().optional(),
sha256: z.string(),
id: z.string(),
mime_type: z.string(),
}),
}),
WhatsAppBaseMessageSchema.extend({
type: z.literal('button'),
button: z.object({
payload: z.string(),
text: z.string(),
}),
}),
WhatsAppBaseMessageSchema.extend({
type: z.literal('location'),
location: z.object({
address: z.string().optional(),
latitude: z.number(),
longitude: z.number(),
name: z.string().optional(),
url: z.string().optional(),
}),
}),
WhatsAppBaseMessageSchema.extend({
type: z.literal('document'),
document: z.object({
caption: z.string().optional(),
filename: z.string(),
sha256: z.string(),
mime_type: z.string(),
id: z.string(),
}),
}),
WhatsAppBaseMessageSchema.extend({
type: z.literal('audio'),
//could be audio file, or voice note
audio: z.object({
id: z.string(),
mime_type: z.string(),
}),
}),
WhatsAppBaseMessageSchema.extend({
type: z.literal('video'),
video: z.object({
caption: z.string().optional(),
sha256: z.string(),
id: z.string(),
mime_type: z.string(),
}),
}),
WhatsAppBaseMessageSchema.extend({
type: z.literal('interactive'),
interactive: WhatsAppMessageInteractiveSchema,
}),
WhatsAppBaseMessageSchema.extend({
type: z.literal('sticker'),
sticker: z.object({
mime_type: z.string(),
sha256: z.string(),
id: z.string(),
animated: z.boolean(),
}),
}),
WhatsAppBaseMessageSchema.extend({
type: z.literal('reaction'), // not documented but can be received
reaction: z.object({
message_id: z.string(),
emoji: z
.string()
.optional()
.title('Emoji')
.describe('The emoji used in the reaction or undefined if the reaction was removed'),
}),
}),
WhatsAppBaseMessageSchema.extend({
type: z.union([
z.literal('order'),
z.literal('system'),
z.literal('unknown'),
z.literal('unsupported'), // not documented but can be received
z.literal('contacts'), // not documented but can be received
]),
}),
])
export type WhatsAppMessage = z.infer<typeof WhatsAppMessageSchema>
export type WhatsAppReactionMessage = WhatsAppMessage & {
type: 'reaction'
}
const WhatsAppStatusSchema = z.object({
id: z.string(),
status: z.enum(['sent', 'delivered', 'read', 'failed']),
timestamp: z.string(),
recipient_id: z.string(),
errors: z
.array(
z.object({
code: z.number(),
title: z.string(),
message: z.string(),
error_data: z
.object({
details: z.string(),
})
.optional(),
})
)
.optional(),
})
export type WhatsAppStatusValue = z.infer<typeof WhatsAppStatusSchema>
const WhatsAppMessageValueSchema = z.object({
messaging_product: z.literal('whatsapp'),
metadata: z.object({
display_phone_number: z.string(),
phone_number_id: z.string(),
}),
contacts: z.array(WhatsAppContactSchema).optional(),
messages: z.array(WhatsAppMessageSchema).optional(),
statuses: z.array(WhatsAppStatusSchema).optional(),
})
export type WhatsAppMessageValue = z.infer<typeof WhatsAppMessageValueSchema>
export const WhatsAppMessageTemplateComponentsUpdateValueSchema = z.object({
message_template_id: z.number(),
message_template_name: z.string(),
message_template_language: z.string(),
message_template_element: z.string(),
message_template_title: z.string().optional(),
message_template_footer: z.string().optional(),
message_template_buttons: z
.array(
z.object({
message_template_button_type: z.enum([
'CATALOG',
'COPY_CODE',
'EXTENSION',
'FLOW',
'MPM',
'ORDER_DETAILS',
'OTP',
'PHONE_NUMBER',
'POSTBACK',
'REMINDER',
'SEND_LOCATION',
'SPM',
'QUICK_REPLY',
'URL',
'VOICE_CALL',
]),
message_template_button_text: z.string(),
message_template_button_url: z.string().optional(),
message_template_button_phone_number: z.string().optional(),
})
)
.optional(),
})
export const WhatsAppMessageTemplateQualityUpdateValueSchema = z.object({
previous_quality_score: qualityScoreSchema,
new_quality_score: qualityScoreSchema,
message_template_id: z.number(),
message_template_name: z.string(),
message_template_language: z.string(),
})
export const WhatsAppMessageTemplateStatusUpdateValueSchema = z.object({
event: z.enum([
'APPROVED',
'ARCHIVED',
'DELETED',
'DISABLED',
'FLAGGED',
'IN_APPEAL',
'LIMIT_EXCEEDED',
'LOCKED',
'PAUSED',
'PENDING',
'REINSTATED',
'PENDING_DELETION',
'REJECTED',
]),
message_template_id: z.number(),
message_template_name: z.string(),
message_template_language: z.string(),
reason: z
.enum([
'ABUSIVE_CONTENT',
'CATEGORY_NOT_AVAILABLE',
'INCORRECT_CATEGORY',
'INVALID_FORMAT',
'NONE',
'PROMOTIONAL',
'SCAM',
'TAG_CONTENT_MISMATCH',
])
.nullable(),
disable_info: z.object({ disable_date: z.number() }).optional(),
other_info: z
.object({
title: z.enum(['FIRST_PAUSE', 'SECOND_PAUSE', 'RATE_LIMITING_PAUSE', 'UNPAUSE', 'DISABLED']),
description: z.string(),
})
.optional(),
})
export const WhatsAppTemplateCategoryUpdateValueSchema = z.object({
message_template_id: z.number(),
message_template_name: z.string(),
message_template_language: z.string(),
correct_category: z.string().optional(),
previous_category: z.string().optional(),
new_category: z.string().optional(),
})
const WhatsAppEchoMessageSchema = WhatsAppMessageSchema.and(
z.object({ to: z.string(), message_creation_type: z.string() })
)
export type WhatsAppEchoMessage = z.infer<typeof WhatsAppEchoMessageSchema>
const WhatsAppMessageEchoValueSchema = z.object({
messaging_product: z.literal('whatsapp'),
metadata: z.object({
display_phone_number: z.string(),
phone_number_id: z.string(),
}),
message_echoes: z.array(WhatsAppEchoMessageSchema),
})
export type WhatsAppMessageEchoValue = z.infer<typeof WhatsAppMessageEchoValueSchema>
const WhatsAppChangesSchema = z.discriminatedUnion('field', [
z.object({
field: z.literal('messages'),
value: WhatsAppMessageValueSchema,
}),
z.object({
field: z.literal('smb_message_echoes'),
value: WhatsAppMessageEchoValueSchema,
}),
z.object({
field: z.literal('message_template_components_update'),
value: WhatsAppMessageTemplateComponentsUpdateValueSchema,
}),
z.object({
field: z.literal('message_template_quality_update'),
value: WhatsAppMessageTemplateQualityUpdateValueSchema,
}),
z.object({
field: z.literal('message_template_status_update'),
value: WhatsAppMessageTemplateStatusUpdateValueSchema,
}),
z.object({
field: z.literal('template_category_update'),
value: WhatsAppTemplateCategoryUpdateValueSchema,
}),
])
const WhatsAppEntrySchema = z.object({
id: z.string(),
changes: z.array(WhatsAppChangesSchema),
})
export const WhatsAppPayloadSchema = z.object({
object: z.literal('whatsapp_business_account'),
entry: z.array(WhatsAppEntrySchema),
})
export type WhatsAppPayload = z.infer<typeof WhatsAppPayloadSchema>
// Schema from https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/components
const componentSchema = z.union([
z.object({
type: z.literal('HEADER'),
format: z.literal('TEXT'),
text: z.string().optional(),
}),
z.object({
type: z.literal('HEADER'),
format: z.enum(['IMAGE', 'VIDEO', 'GIF', 'DOCUMENT']),
example: z.object({
header_handle: z.array(z.string()),
}),
}),
z.object({
type: z.literal('HEADER'),
format: z.undefined(),
parameters: z.array(
z.object({
type: z.literal('location'),
location: z.object({
latitude: z.string(),
longitude: z.string(),
name: z.string(),
address: z.string(),
}),
})
),
}),
z.object({
type: z.literal('BODY'),
text: z.string().optional(),
}),
z.object({
type: z.literal('FOOTER'),
text: z.string().optional(),
}),
z.object({
type: z.literal('BUTTONS'),
buttons: z.array(z.object({ text: z.string().optional() })),
}),
z.object({
type: z.literal('FLOW'),
text: z.string().optional(),
}),
z.object({
type: z.literal('PHONE_NUMBER'),
text: z.string().optional(),
}),
z.object({
type: z.literal('QUICK_REPLY'),
text: z.string().optional(),
}),
z.object({
type: z.literal('URL'),
text: z.string().optional(),
}),
])
export type Component = z.infer<typeof componentSchema>
export const positionalVariablesSchema = z.array(z.string().or(z.number()))
export type PositionalVariables = z.infer<typeof positionalVariablesSchema>
export const namedVariablesSchema = z.record(z.string(), z.string().or(z.number()))
export type NamedVariables = z.infer<typeof namedVariablesSchema>
export const templateVariablesSchema = z.union([positionalVariablesSchema, namedVariablesSchema])
export type TemplateVariables = z.infer<typeof templateVariablesSchema>
export const keyValuePairSchema = z.object({ key: z.string(), value: z.string() })
export type KeyValuePair = z.infer<typeof keyValuePairSchema>
export type TemplateHeaderParam =
| { type: 'text'; value: string; parameterName?: string }
| { type: 'image'; url: string }
| { type: 'video'; url: string }
| { type: 'document'; url: string; filename?: string }
export type TemplateBodyParams =
| { type: 'positional'; values: string[] }
| { type: 'named'; values: Record<string, string> }
export type TemplateButtonParam =
| { type: 'url'; value: string }
| { type: 'quick_reply'; payload: string }
| { type: 'copy_code'; code: string }
| { type: 'skip' }
+86
View File
@@ -0,0 +1,86 @@
import * as sdk from '@botpress/sdk'
import { AtLeastOne } from 'whatsapp-api-js/lib/utils'
import * as bp from '.botpress'
type Message = Awaited<ReturnType<bp.Client['listMessages']>>['messages'][number]
export function chunkArray<T>(array: T[], chunkSize: number) {
const chunks: T[][] = []
if (chunkSize <= 0) {
return chunks
}
for (let i = 0; i < array.length; i += chunkSize) {
chunks.push(array.slice(i, i + chunkSize))
}
return chunks
}
export async function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export function truncate(input: string, maxLength: number) {
let truncated = input.substring(0, maxLength)
if (truncated.length < input.length) {
truncated = truncated.substring(0, maxLength - 1) + '…'
}
return truncated
}
export function getSubpath(path: string) {
let subpath = '/' + path.split('/').slice(2).join('/')
if (subpath.slice(-1) === '/') {
subpath = subpath.slice(0, -1)
}
return subpath ? subpath : undefined
}
export const hasAtleastOne = <T>(obj: T[]): obj is AtLeastOne<T> => {
return obj.length > 0
}
export const getMessageFromWhatsappMessageId = async (
messageId: string,
client: bp.Client
): Promise<Message | undefined> => {
const { messages } = await client.listMessages({
tags: {
id: messageId,
},
})
return messages[0]
}
export function logForBotAndThrow(message: string, logger: bp.Logger): never {
logger.forBot().error(message)
throw new sdk.RuntimeError(message)
}
type IssueLogEvent = Parameters<bp.Logger['issue']>[0]
type ReportIssueOptions = Omit<IssueLogEvent, 'type' | 'groupBy' | 'data'> & {
/** Defaults to `[code]`. */
groupBy?: string[]
/** Defaults to `{}`. */
data?: IssueLogEvent['data']
/** Message for the thrown RuntimeError. Defaults to `description`. */
throwMessage?: string
}
/**
* Logs the error for the bot, reports a structured issue (so it surfaces in Desk),
* then throws a RuntimeError so the failure is never silently swallowed.
*/
export function reportIssueAndThrow(
logger: bp.Logger,
{ throwMessage, groupBy, data, ...issue }: ReportIssueOptions
): never {
logger.forBot().error(issue.description)
logger.issue({
...issue,
type: 'issue',
groupBy: groupBy ?? [issue.code],
data: data ?? {},
})
throw new sdk.RuntimeError(throwMessage ?? issue.description)
}
@@ -0,0 +1,32 @@
import { RuntimeError } from '@botpress/sdk'
import { getAuthenticatedWhatsappClient } from '../auth'
import * as bp from '.botpress'
export async function getMediaInfos(whatsappMediaId: string, client: bp.Client, ctx: bp.Context) {
const whatsapp = await getAuthenticatedWhatsappClient(client, ctx)
const media = await whatsapp.retrieveMedia(whatsappMediaId)
if ('error' in media) {
throw new RuntimeError(
`Failed to retrieve media URL for media ID "${whatsappMediaId}": ${media.error?.message ?? 'unknown error'}`
)
} else if (!('messaging_product' in media)) {
throw new RuntimeError('Failed to retrieve media URL: invalid response from WhatsApp API')
}
const { url, mime_type: mimeType, file_size: fileSizeStr } = media
const fileSize = Number(fileSizeStr)
if (isNaN(fileSize)) {
throw new RuntimeError(`Failed to parse file size from media response: ${fileSizeStr}`)
}
return {
url,
mimeType,
fileSize,
}
}
export const getMediaUrl = async (whatsappMediaId: string, client: bp.Client, ctx: bp.Context) => {
const { url } = await getMediaInfos(whatsappMediaId, client, ctx)
return url
}
+70
View File
@@ -0,0 +1,70 @@
import { test, expect, vi } from 'vitest'
import { repeat } from './repeat'
test('repeat calls the function the specified number of times', async () => {
const callback = vi
.fn()
.mockResolvedValueOnce({ repeat: true, result: 1 })
.mockResolvedValueOnce({ repeat: true, result: 2 })
.mockResolvedValueOnce({ repeat: false, result: 3 })
const backoff = vi.fn().mockReturnValue(0)
const result = await repeat<number>(callback, { maxIterations: 5, backoff })
expect(result).toBe(3)
expect(callback).toHaveBeenCalledTimes(3)
expect(backoff).toHaveBeenCalledTimes(2)
expect(backoff).toHaveBeenNthCalledWith(1, 1, 1)
expect(backoff).toHaveBeenNthCalledWith(2, 2, 2)
})
test('repeat stops after maxIterations', async () => {
const callback = vi
.fn()
.mockResolvedValueOnce({ repeat: true, result: 1 })
.mockResolvedValueOnce({ repeat: true, result: 2 })
.mockResolvedValueOnce({ repeat: false, result: 3 })
const backoff = vi.fn().mockReturnValue(0)
const result = await repeat<number>(callback, { maxIterations: 2, backoff })
expect(result).toBe(2)
expect(callback).toHaveBeenCalledTimes(2)
expect(backoff).toHaveBeenCalledTimes(1)
expect(backoff).toHaveBeenNthCalledWith(1, 1, 1)
})
test('repeat uses the backoff function to determine delay', async () => {
const timestamps: number[] = []
let t0 = Date.now()
let iteration = 0
let cb = async () => {
timestamps.push(Date.now() - t0)
t0 = Date.now()
iteration++
if (iteration < 3) {
return { repeat: true, result: 'failure' }
}
return { repeat: false, result: 'success' }
}
const backoff = (iteration: number) => iteration * 100
const result = await repeat<string>(cb, { maxIterations: 5, backoff })
const BUFFER = 25
expect(result).toBe('success')
expect(timestamps.length).toBe(3)
expect(timestamps[0]).toBeLessThan(BUFFER) // First call, no delay
expect(timestamps[1]).toBeGreaterThanOrEqual(100 - BUFFER)
expect(timestamps[1]).toBeLessThan(100 + BUFFER)
expect(timestamps[2]).toBeGreaterThanOrEqual(200 - BUFFER)
expect(timestamps[2]).toBeLessThan(200 + BUFFER)
})
+36
View File
@@ -0,0 +1,36 @@
import { sleep } from './misc/util'
export type RepeatResult<T> = {
repeat: boolean
result: T
}
export type RepeatCallback<T> = (iteration: number) => Promise<RepeatResult<T>>
export type BackoffCallback<T> = (iteration: number, result: T) => number
export type RepeatOptions<T> = {
maxIterations: number
backoff: BackoffCallback<T>
}
/**
* Like a retry function, but with a slightly different semantics.
* The callback function is repeated based on its return value, not on whether it throws or not.
*/
export const repeat = async <T>(callback: RepeatCallback<T>, options: RepeatOptions<T>): Promise<T> => {
let iteration = 0
for (;;) {
const res = await callback(iteration)
if (!res.repeat) {
return res.result
}
iteration++
if (iteration >= options.maxIterations) {
return res.result
}
const delay = options.backoff(iteration, res.result)
await sleep(delay)
}
}
+71
View File
@@ -0,0 +1,71 @@
import { RuntimeError } from '@botpress/sdk'
import { MetaOauthClient } from './auth'
import * as bp from '.botpress'
export const register: bp.IntegrationProps['register'] = async (props) => {
const configTypeName = props.ctx.configurationType ? props.ctx.configurationType : 'OAuth'
props.logger.forBot().debug(`Whatsapp Registration with configurationType ${configTypeName}`)
// Always make sure a bot is dissociated from WhatsApp conversations once the configuration type changes
const configureIntegrationProps: Parameters<typeof props.client.configureIntegration>[0] = {
sandboxIdentifiers: null,
}
// Ensure that requests sent to a profile associated with a bot via OAuth are not received by the bot
if (props.ctx.configurationType !== null) {
configureIntegrationProps.identifier = null
}
await props.client.configureIntegration(configureIntegrationProps)
if (props.ctx.configurationType !== 'manual') {
return // nothing more to do if we're not using manual configuration
}
const { accessToken, defaultBotPhoneNumberId, verifyToken, appId, clientSecret } = props.ctx.configuration
// appId and clientSecret are optional and not required for validation
if (accessToken && defaultBotPhoneNumberId && verifyToken) {
// let's check the credentials
const isValidConfiguration = await _checkManualConfiguration(accessToken)
if (!isValidConfiguration) {
throw new RuntimeError('Error! Please check your credentials and webhook.')
}
// When both the App ID and Client Secret are provided, automatically configure the webhook on
// the user's Meta app so they don't have to do it manually in the Meta dashboard.
if (appId && clientSecret) {
try {
const oauthClient = new MetaOauthClient(props.logger)
await oauthClient.configureAppWebhookSubscription({
appId,
appSecret: clientSecret,
verifyToken,
callbackUrl: props.webhookUrl,
})
} catch (thrown: unknown) {
const errMsg = thrown instanceof Error ? thrown.message : 'Unknown error thrown'
props.logger.forBot().warn(`Could not automatically configure the webhook on your Meta app: ${errMsg}`)
}
}
} else {
throw new RuntimeError('Error! Please add the missing fields and save.')
}
}
export const unregister: bp.IntegrationProps['unregister'] = async () => {}
async function _checkManualConfiguration(accessToken: string) {
// get appId first
const appResponse = await fetch('https://graph.facebook.com/app', {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
})
if (!appResponse.ok) {
return false // Invalid access token
}
const appId = (await appResponse.json()).id
return !!appId // TODO: check if webhook is configured, this may require a permission change.
}
@@ -0,0 +1,193 @@
import { Request } from '@botpress/sdk'
import * as crypto from 'crypto'
import { getClientSecret } from '../auth'
import { WhatsAppPayload, WhatsAppPayloadSchema } from '../misc/types'
import { echoHandler } from './handlers/echo'
import { messagesHandler } from './handlers/messages'
import { oauthCallbackHandler } from './handlers/oauth'
import { reactionHandler } from './handlers/reaction'
import { isSandboxCommand, sandboxHandler } from './handlers/sandbox'
import { statusHandler } from './handlers/status'
import { subscribeHandler } from './handlers/subscribe'
import * as bp from '.botpress'
const _handler: bp.IntegrationProps['handler'] = async (props: bp.HandlerProps) => {
const { req, logger, client } = props
if (req.path.startsWith('/oauth')) {
return await oauthCallbackHandler(props)
}
if (isSandboxCommand(props)) {
return await sandboxHandler(props)
}
logger.debug('Received request with body:', req.body ?? '[empty]')
const queryParams = new URLSearchParams(req.query)
if (queryParams.has('hub.mode')) {
return await subscribeHandler(props)
}
const validationResult = _validateRequestAuthentication(req, props)
if (validationResult.error) {
return { status: 401, body: validationResult.message }
}
if (!req.body) {
logger.debug('Handler received an empty body, so the message was ignored')
return
}
let payload: WhatsAppPayload
try {
const data = JSON.parse(req.body)
payload = WhatsAppPayloadSchema.parse(data)
} catch (e: any) {
logger.debug('Error while handling request:', e)
return { status: 500, body: 'Error while handling request: ' + e.message }
}
const changes = payload.entry[0]?.changes[0]
if (!changes) {
logger.debug('No changes found in the payload, ignoring message')
return
}
if (!changes.value) {
logger.debug('No value found in the payload, ignoring message')
return
}
switch (changes.field) {
case 'smb_message_echoes':
for (const echo of changes.value.message_echoes) {
try {
await echoHandler(echo, changes.value, props)
} catch (thrown: unknown) {
const errMsg = thrown instanceof Error ? thrown.message : 'Unknown error thrown'
logger.forBot().error(`Failed to process WhatsApp echo event: ${errMsg}`)
}
}
break
case 'messages':
for (const message of changes.value.messages ?? []) {
if (message.type === 'reaction') {
await reactionHandler(message, props)
} else {
await messagesHandler(message, changes.value, props)
}
}
for (const status of changes.value.statuses ?? []) {
try {
await statusHandler(status, props)
} catch (thrown: unknown) {
const errMsg = thrown instanceof Error ? thrown.message : 'Unknown error thrown'
logger.forBot().error(`Failed to process WhatsApp status event: ${errMsg}`)
}
}
break
case 'message_template_components_update':
await client.createEvent({
type: 'messageTemplateComponentsUpdate',
payload: {
id: changes.value.message_template_id,
name: changes.value.message_template_name,
language: changes.value.message_template_language,
element: changes.value.message_template_element,
title: changes.value.message_template_title,
footer: changes.value.message_template_footer,
buttons: changes.value.message_template_buttons?.map((button) => ({
button_type: button.message_template_button_type,
button_text: button.message_template_button_text,
button_url: button.message_template_button_url,
button_phone_number: button.message_template_button_phone_number,
})),
},
})
break
case 'message_template_quality_update':
await client.createEvent({
type: 'messageTemplateQualityUpdate',
payload: {
...changes.value,
id: changes.value.message_template_id,
name: changes.value.message_template_name,
language: changes.value.message_template_language,
},
})
break
case 'message_template_status_update':
await client.createEvent({
type: 'messageTemplateStatusUpdate',
payload: {
...changes.value,
id: changes.value.message_template_id,
name: changes.value.message_template_name,
language: changes.value.message_template_language,
},
})
break
case 'template_category_update':
await client.createEvent({
type: 'templateCategoryUpdate',
payload: {
...changes.value,
id: changes.value.message_template_id,
name: changes.value.message_template_name,
language: changes.value.message_template_language,
},
})
break
default:
logger.forBot().info('The event sent to the bot is not yet handled by botpress.')
}
}
const _validateRequestAuthentication = (
req: Request,
{ ctx }: { ctx: bp.Context }
): { error: true; message: string } | { error: false } => {
const secret = getClientSecret(ctx)
if (!secret) {
return { error: false }
}
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(req.body ?? '')
.digest('hex')
const signature = req.headers['x-hub-signature-256']?.split('=')[1]
if (signature !== expectedSignature) {
return {
error: true,
message: `Invalid signature (got ${signature ?? 'none'}, expected ${expectedSignature}).\n${_getSecretErrorText(secret)}`,
}
}
return { error: false }
}
const _getSecretErrorText = (secret: string): string => {
let bpClientSecretText = undefined
if (secret === bp.secrets.SANDBOX_CLIENT_SECRET) {
bpClientSecretText = 'The sandbox'
}
if (secret === bp.secrets.CLIENT_SECRET) {
bpClientSecretText = 'The OAuth'
}
return `${bpClientSecretText ? bpClientSecretText : 'A manual configured'} client secret was used to validate the signature`
}
const _handlerWrapper: typeof _handler = async (props: bp.HandlerProps) => {
try {
const response = await _handler(props)
if (response?.status && response.status !== 200) {
props.logger.error(`WhatsApp handler failed with status ${response.status}: ${response.body}`)
}
return response
} catch (thrown: unknown) {
const errMsg = thrown instanceof Error ? thrown.message : 'Unknown error thrown'
const errorMessage = `Webhook handler failed with error: ${errMsg}`
props.logger.error(errorMessage)
return { status: 500, body: errorMessage }
}
}
export const handler = _handlerWrapper
@@ -0,0 +1,69 @@
import { safeFormatPhoneNumber } from '../../misc/phone-number-to-whatsapp'
import { WhatsAppMessageEchoValue, WhatsAppEchoMessage } from '../../misc/types'
import { _handleMessage } from './messages'
import * as bp from '.botpress'
export const echoHandler = async (
echo: WhatsAppEchoMessage,
value: WhatsAppMessageEchoValue,
props: bp.HandlerProps
) => {
const { ctx, client, logger } = props
const formatPhoneNumberResponse = safeFormatPhoneNumber(echo.to)
if (formatPhoneNumberResponse.success === false) {
logger
.forBot()
.error(`Failed to parse recipient phone number "${echo.to}": ${formatPhoneNumberResponse.error.message}`)
}
const userPhone = formatPhoneNumberResponse.success ? formatPhoneNumberResponse.phoneNumber : echo.to
const { conversation } = await client.getOrCreateConversation({
channel: 'channel',
tags: {
userPhone,
botPhoneNumberId: value.metadata.phone_number_id,
},
})
const { user } = await client.getOrCreateUser({
tags: { number: echo.from },
name: echo.from,
discriminateByTags: ['number'],
})
const newMessage = await _handleMessage({
message: echo,
conversationId: conversation.id,
userId: user.id,
ctx,
client,
logger,
tags: { id: echo.id, echoCreationType: echo.message_creation_type },
createMessageOverride: async ({ type, payload }) => {
const { messages } = await client._inner.importMessages({
messages: [
{
type,
payload,
userId: user.id,
conversationId: conversation.id,
tags: { id: echo.id, echoCreationType: echo.message_creation_type },
createdAt: new Date(parseInt(echo.timestamp) * 1000).toISOString(),
discriminateByTags: ['id'],
},
],
})
return messages[0] ? { message: messages[0] } : undefined
},
})
if (newMessage && ctx.configuration.listenMessageEchoes) {
await client.createEvent({
type: 'onMessageEchoReceived',
conversationId: conversation.id,
messageId: newMessage.message.id,
payload: {},
})
}
}
@@ -0,0 +1,312 @@
import { RuntimeError } from '@botpress/client'
import { posthogHelper } from '@botpress/common'
import { ValueOf } from '@botpress/sdk/dist/utils/type-utils'
import axios from 'axios'
import { INTEGRATION_NAME, INTEGRATION_VERSION } from 'integration.definition'
import { getAccessToken, getAuthenticatedWhatsappClient } from '../../auth'
import { safeFormatPhoneNumber } from '../../misc/phone-number-to-whatsapp'
import { WhatsAppMessage, WhatsAppMessageValue } from '../../misc/types'
import { getMessageFromWhatsappMessageId } from '../../misc/util'
import { getMediaInfos } from '../../misc/whatsapp-utils'
import * as bp from '.botpress'
type IncomingMessages = {
[TMessage in keyof bp.channels.channel.Messages]: {
type: TMessage
payload: bp.channels.channel.Messages[TMessage]
}
}
type CreateMessageArgs = ValueOf<IncomingMessages> & { incomingMessageType?: string }
type CreateMessageFn = (args: CreateMessageArgs) => Promise<{ message: { id: string } } | undefined>
export type HandleMessageArgs = {
message: WhatsAppMessage
conversationId: string
userId: string
ctx: bp.Context
client: bp.Client
logger: bp.Logger
tags: Record<string, string>
origin?: 'synthetic'
createMessageOverride?: CreateMessageFn
}
export const messagesHandler = async (
message: NonNullable<WhatsAppMessageValue['messages']>[number],
value: WhatsAppMessageValue,
props: bp.HandlerProps
) => {
const { ctx, client, logger } = props
const whatsapp = await getAuthenticatedWhatsappClient(client, ctx)
const phoneNumberId = value.metadata.phone_number_id
await whatsapp.markAsRead(phoneNumberId, message.id)
const { contacts } = value
const contact = contacts?.[0]
if (!contact) {
logger.forBot().warn('No contacts found, ignoring message')
return
}
const waUserId = message.from_user_id ?? contact.user_id
let userPhone: string | undefined
if (message.from) {
const formatPhoneNumberResponse = safeFormatPhoneNumber(message.from)
if (formatPhoneNumberResponse.success === false) {
const distinctId = formatPhoneNumberResponse.error.id
await posthogHelper.sendPosthogEvent(
{
distinctId: distinctId ?? 'no id',
event: 'invalid_phone_number',
properties: {
from: 'handler',
phoneNumber: message.from,
},
},
{ integrationName: INTEGRATION_NAME, integrationVersion: INTEGRATION_VERSION, key: bp.secrets.POSTHOG_KEY }
)
const errorMessage = formatPhoneNumberResponse.error.message
logger.error(`Failed to parse phone number "${message.from}": ${errorMessage}`)
}
userPhone = formatPhoneNumberResponse.success ? formatPhoneNumberResponse.phoneNumber : message.from
}
const { conversation } = await client.getOrCreateConversation({
channel: 'channel',
tags: {
...(userPhone && { userPhone }),
...(waUserId && { userId: waUserId }),
botPhoneNumberId: value.metadata.phone_number_id,
},
// Keep the legacy userPhone-based identity whenever a phone number is available so existing
// conversations keep matching. Only opted-in users (no phone) are identified by the stable
// WhatsApp user_id.
discriminateByTags: userPhone ? ['botPhoneNumberId', 'userPhone'] : ['botPhoneNumberId', 'userId'],
})
// Keep the legacy phone-based identity (`wa_id`) as the user key whenever it's available so
// existing users keep matching instead of being recreated. Opted-in users (no `wa_id`) are
// identified by the stable WhatsApp user_id. The user_id is additionally stored on every user
// for forward reference.
const userIdentity = contact.wa_id ?? contact.user_id
const { user } = await client.getOrCreateUser({
tags: {
...(userIdentity && { userId: userIdentity }),
...(contact.user_id && { whatsappUserId: contact.user_id }),
...(contact.wa_id && { number: contact.wa_id }),
...(contact.profile?.username && { username: contact.profile.username }),
name: contact.profile?.name,
},
name: contact.profile?.name,
discriminateByTags: ['userId'],
})
const replyToWhatsAppId = message.context?.id
const replyToMessage = replyToWhatsAppId
? await getMessageFromWhatsappMessageId(replyToWhatsAppId, client)
: undefined
if (replyToWhatsAppId && !replyToMessage) {
// Only thing we can do is log
// We can't fetch a message from the API if we didn't receive it on the webhook
logger
.forBot()
.warn(
`No Botpress message was found for the referenced message with WhatsApp message ID ${replyToWhatsAppId}. The bot may not be able to retrieve the context.`
)
}
const replyTo = replyToMessage?.id
await _handleMessage({
message,
conversationId: conversation.id,
userId: user.id,
ctx,
client,
logger,
tags: {
id: message.id,
...(replyTo && { replyTo }),
..._processReferralTags(message, logger),
},
})
}
export async function _handleMessage(args: HandleMessageArgs) {
const { message, conversationId, userId, ctx, client, logger, tags, origin } = args
const _createMessage: CreateMessageFn =
args.createMessageOverride ??
(async ({ type, payload, incomingMessageType }) => {
logger.forBot().debug(`Received ${incomingMessageType ?? type} message from WhatsApp:`, payload)
return client.getOrCreateMessage({
tags,
type,
payload,
userId,
conversationId,
discriminateByTags: ['id'],
origin,
})
})
const { type } = message
if (type === 'text') {
return _createMessage({ type, payload: { text: message.text.body } })
} else if (type === 'button') {
return _createMessage({
type: 'text',
payload: {
value: message.button.payload,
text: message.button.text,
},
})
} else if (type === 'location') {
const { latitude, longitude, address, name } = message.location
return _createMessage({
type,
payload: { latitude: Number(latitude), longitude: Number(longitude), title: name, address },
})
} else if (type === 'image') {
const imageUrl = await _getOrDownloadWhatsappMedia(message.image.id, client, ctx)
return _createMessage({
type,
payload: {
imageUrl,
...(message.image.caption && { caption: message.image.caption }),
},
})
} else if (type === 'sticker') {
const stickerUrl = await _getOrDownloadWhatsappMedia(message.sticker.id, client, ctx)
return _createMessage({ type: 'image', payload: { imageUrl: stickerUrl } })
} else if (type === 'audio') {
const audioUrl = await _getOrDownloadWhatsappMedia(message.audio.id, client, ctx)
return _createMessage({ type, payload: { audioUrl } })
} else if (type === 'document') {
const documentUrl = await _getOrDownloadWhatsappMedia(message.document.id, client, ctx)
return _createMessage({
type: 'file',
payload: { fileUrl: documentUrl, filename: message.document.filename },
})
} else if (type === 'video') {
const videoUrl = await _getOrDownloadWhatsappMedia(message.video.id, client, ctx)
return _createMessage({ type, payload: { videoUrl } })
} else if (message.type === 'interactive') {
if (message.interactive.type === 'button_reply') {
const { id: value, title: text } = message.interactive.button_reply
return _createMessage({
type: 'text',
payload: { value, text },
incomingMessageType: type,
})
} else if (message.interactive.type === 'list_reply') {
const { id: value, title: text } = message.interactive.list_reply
return _createMessage({
type: 'text',
payload: { value, text },
incomingMessageType: type,
})
}
} else if (message.type === 'unsupported' || message.type === 'unknown') {
const errors = message.errors?.map((err) => `${err.message} (${err.error_data.details})`).join('\n')
logger.forBot().warn(`Received message type ${message.type} by WhatsApp, errors: ${errors ?? 'none'}`)
} else {
logger.forBot().warn(`Unhandled message type ${type}: ${JSON.stringify(message)}`)
}
return undefined
}
async function _getOrDownloadWhatsappMedia(whatsappMediaId: string, client: bp.Client, ctx: bp.Context) {
if (ctx.configuration.downloadMedia) {
return await _downloadWhatsappMedia(whatsappMediaId, client, ctx)
} else {
const { url } = await getMediaInfos(whatsappMediaId, client, ctx)
return url
}
}
async function _downloadWhatsappMedia(whatsappMediaId: string, client: bp.Client, ctx: bp.Context) {
const { url, mimeType, fileSize } = await getMediaInfos(whatsappMediaId, client, ctx)
const { file } = await client.upsertFile({
key: 'whatsapp-media_' + whatsappMediaId,
expiresAt: _getMediaExpiry(ctx),
contentType: mimeType,
accessPolicies: ['public_content'],
publicContentImmediatelyAccessible: true,
size: fileSize,
tags: {
source: 'integration',
integration: 'whatsapp',
channel: 'channel',
whatsappMediaId,
},
})
const downloadResponse = await axios
.get(url, {
responseType: 'stream',
headers: {
Authorization: `Bearer ${await getAccessToken(client, ctx)}`,
},
})
.catch((err) => {
throw new RuntimeError(`Failed to download media: ${err.message}`)
})
await axios
.put(file.uploadUrl, downloadResponse.data, {
headers: {
'Content-Type': mimeType,
'Content-Length': fileSize,
'x-amz-tagging': 'public=true',
},
maxBodyLength: fileSize,
})
.catch((err) => {
throw new RuntimeError(`Failed to upload media: ${err.message}`)
})
return file.url
}
function _getMediaExpiry(ctx: bp.Context) {
const expiryDelayHours = ctx.configuration.downloadedMediaExpiry || 0
if (expiryDelayHours === 0) {
return undefined
}
const expiresAt = new Date(Date.now() + expiryDelayHours * 60 * 60 * 1000)
return expiresAt.toISOString()
}
function _processReferralTags(message: WhatsAppMessage, logger: bp.Logger): Record<string, string> {
const { referral } = message
if (!referral) {
return {}
}
const tags: Record<string, string> = {}
if (referral.source_url) {
const originalUrl = referral.source_url
// Urls can go up to 2048 characters, but we limit to 500 to avoid tags limit error
const processedUrl = originalUrl.slice(0, 500)
if (originalUrl !== processedUrl) {
logger
.forBot()
.warn(
`For whatsapp message "${message.id}", referral source URL was truncated from ${originalUrl.length} to 500 characters. Original: ${originalUrl}, Sliced: ${processedUrl}`
)
}
tags.referralSourceUrl = processedUrl
}
if (referral.source_id) {
tags.referralSourceId = referral.source_id
}
return tags
}
@@ -0,0 +1,22 @@
import { isOAuthWizardUrl, getInterstitialUrl, generateRedirection } from '@botpress/common/src/oauth-wizard'
import * as wizard from './wizard'
import * as bp from '.botpress'
export const oauthCallbackHandler: bp.IntegrationProps['handler'] = async (props) => {
const { req, logger } = props
if (!isOAuthWizardUrl(req.path)) {
return {
status: 404,
body: 'Invalid OAuth endpoint',
}
}
try {
return await wizard.handler(props)
} catch (thrown: unknown) {
const error = thrown instanceof Error ? thrown : Error(String(thrown))
const errorMessage = 'OAuth registration Error: ' + error.message
logger.forBot().error(errorMessage)
return generateRedirection(getInterstitialUrl(false, errorMessage))
}
}
@@ -0,0 +1,299 @@
import * as oauthWizard from '@botpress/common/src/oauth-wizard'
import { Response, z } from '@botpress/sdk'
import * as bp from '../../../../.botpress'
import { MetaOauthClient } from '../../../auth'
export type WizardHandlerProps = bp.HandlerProps & { wizardPath: string }
type WizardHandler = oauthWizard.WizardStepHandler<bp.HandlerProps>
type Credentials = Awaited<ReturnType<typeof _getCredentialsState>>
const ACCESS_TOKEN_UNAVAILABLE_ERROR = 'Access token unavailable, please try again.'
const WABA_ID_UNAVAILABLE_ERROR = 'WhatsApp Business Account ID unavailable, please try again.'
const PHONE_NUMBER_ID_UNAVAILABLE_ERROR = 'Phone number ID unavailable, please try again.'
export const handler = async (props: bp.HandlerProps): Promise<Response> => {
const wizard = new oauthWizard.OAuthWizardBuilder(props)
.addStep({
id: 'start-confirm',
handler: _startConfirmHandler,
})
.addStep({
id: 'setup',
handler: _setupHandler,
})
.addStep({
id: 'get-access-token',
handler: _getAccessTokenHandler,
})
.addStep({
id: 'verify-waba',
handler: _verifyWabaHandler,
})
.addStep({
id: 'verify-number',
handler: _verifyNumberHandler,
})
.addStep({
id: 'wrap-up',
handler: _doStepWrapUp,
})
.addStep({
id: 'finish-wrap-up',
handler: _doStepFinishWrapUp,
})
.addStep({
id: 'end',
handler: _endHandler,
})
.build()
props.logger.forBot().debug('The OAuth wizard was started.')
const response = await wizard.handleRequest()
return response
}
const _startConfirmHandler: WizardHandler = async (props) => {
const { responses } = props
// When nothing is connected yet there's nothing to reset, so skip the
// confirmation and go straight to the next step.
if (!(await _isAlreadyConnected(props))) {
return _setupHandler(props)
}
return responses.displayButtons({
pageTitle: 'Reset Configuration',
htmlOrMarkdownPageContents:
'This wizard will reset your configuration, so the bot will stop working on WhatsApp until a new configuration is put in place, continue?',
buttons: [
{ label: 'Yes', buttonType: 'primary', action: 'navigate', navigateToStep: 'setup' },
{ label: 'No', buttonType: 'secondary', action: 'close' },
],
})
}
const _isAlreadyConnected = async ({ client, ctx }: bp.HandlerProps): Promise<boolean> => {
try {
const { accessToken } = await _getCredentialsState(client, ctx)
return Boolean(accessToken)
} catch {
return false
}
}
const _setupHandler: WizardHandler = async (props) => {
const { responses, client, ctx } = props
// Clean current state to start a fresh wizard
const credentials: Credentials = { accessToken: undefined, wabaId: undefined, defaultBotPhoneNumberId: undefined }
await _patchCredentialsState(client, ctx, credentials)
return responses.redirectToExternalUrl(
'https://www.facebook.com/v19.0/dialog/oauth?' +
'client_id=' +
bp.secrets.CLIENT_ID +
'&redirect_uri=' +
_getOAuthRedirectUri() +
'&state=' +
ctx.webhookId +
'&config_id=' +
bp.secrets.OAUTH_CONFIG_ID +
'&override_default_response_type=true' +
'&response_type=code' +
'&extras={"featureType":"whatsapp_business_app_onboarding","sessionInfoVersion":"3","version":"v3","features":[]}'
)
}
const _getAccessTokenHandler: WizardHandler = async (props) => {
const { req, client, ctx, logger } = props
const params = new URLSearchParams(req.query)
const code = z.string().safeParse(params.get('code')).data
if (!code) {
throw new Error('Error extracting code from url in OAuth handler')
}
const oauthClient = new MetaOauthClient(logger)
const redirectUri = _getOAuthRedirectUri() // Needs to be the same as the one used to get the code
const accessToken = await oauthClient.exchangeAuthorizationCodeForAccessToken(code, redirectUri)
if (!accessToken) {
throw new Error(ACCESS_TOKEN_UNAVAILABLE_ERROR)
}
const credentials = await _getCredentialsState(client, ctx)
const newCredentials = { ...credentials, accessToken }
await _patchCredentialsState(client, ctx, newCredentials)
return await _doStepVerifyWaba(props, newCredentials)
}
const _verifyWabaHandler: WizardHandler = async (props) => {
const { selectedChoice } = props
return await _doStepVerifyWaba(props, undefined, selectedChoice)
}
const _verifyNumberHandler: WizardHandler = async (props) => {
const { selectedChoice } = props
return await _doStepVerifyNumber(props, undefined, selectedChoice)
}
const _doStepVerifyWaba = async (
props: Parameters<WizardHandler>[number],
credentials?: Credentials,
inWabaId?: string
): Promise<Response> => {
const { responses, client, ctx, logger } = props
let tmpCredentials = credentials
if (!tmpCredentials) {
tmpCredentials = await _getCredentialsState(client, ctx)
}
let wabaId = inWabaId || tmpCredentials.wabaId
const { accessToken } = tmpCredentials
if (!accessToken) {
throw new Error(ACCESS_TOKEN_UNAVAILABLE_ERROR)
}
const oauthClient = new MetaOauthClient(logger)
if (!wabaId) {
const businesses = await oauthClient.getWhatsappBusinessesFromToken(accessToken)
if (businesses.length === 1) {
wabaId = businesses[0]?.id
} else {
return responses.displayChoices({
pageTitle: 'Select Business',
htmlOrMarkdownPageContents: 'Choose a WhatsApp Business Account to use in this bot:',
choices: businesses.map((business) => ({ value: business.id, label: business.name })),
nextStepId: 'verify-waba',
})
}
}
if (!wabaId) {
throw new Error(WABA_ID_UNAVAILABLE_ERROR)
}
const newCredentials = { ...tmpCredentials, wabaId }
await _patchCredentialsState(client, ctx, newCredentials)
return await _doStepVerifyNumber(props, newCredentials)
}
const _doStepVerifyNumber = async (
props: Parameters<WizardHandler>[number],
credentials?: Credentials,
inDefaultBotPhoneNumberId?: string
): Promise<Response> => {
const { responses, client, ctx, logger } = props
let tmpCredentials = credentials
if (!tmpCredentials) {
tmpCredentials = await _getCredentialsState(client, ctx)
}
let defaultBotPhoneNumberId = inDefaultBotPhoneNumberId || tmpCredentials.defaultBotPhoneNumberId
const { accessToken, wabaId } = tmpCredentials
if (!accessToken) {
throw new Error(ACCESS_TOKEN_UNAVAILABLE_ERROR)
}
if (!wabaId) {
throw new Error(WABA_ID_UNAVAILABLE_ERROR)
}
const oauthClient = new MetaOauthClient(logger)
if (!defaultBotPhoneNumberId) {
const phoneNumbers = await oauthClient.getWhatsappNumbersFromBusiness(wabaId, accessToken)
if (phoneNumbers.length === 1) {
defaultBotPhoneNumberId = phoneNumbers[0]?.id
} else {
return responses.displayChoices({
pageTitle: 'Select the default number',
htmlOrMarkdownPageContents:
'Choose a phone number from the current WhatsApp Business Account to use as default:',
choices: phoneNumbers.map((phoneNumber) => ({
value: phoneNumber.id,
label: `${phoneNumber.displayPhoneNumber} (${phoneNumber.verifiedName})`,
})),
nextStepId: 'verify-number',
})
}
}
if (!defaultBotPhoneNumberId) {
throw new Error(PHONE_NUMBER_ID_UNAVAILABLE_ERROR)
}
const newCredentials = { ...tmpCredentials, defaultBotPhoneNumberId }
await _patchCredentialsState(client, ctx, newCredentials)
return await _doStepWrapUp(props)
}
const _doStepWrapUp: WizardHandler = async (props) => {
const { responses, client, ctx, logger, setIntegrationIdentifier } = props
const credentials = await _getCredentialsState(client, ctx)
const { accessToken, wabaId, defaultBotPhoneNumberId } = credentials
if (!accessToken) {
throw new Error(ACCESS_TOKEN_UNAVAILABLE_ERROR)
}
if (!wabaId) {
throw new Error(WABA_ID_UNAVAILABLE_ERROR)
}
if (!defaultBotPhoneNumberId) {
throw new Error(PHONE_NUMBER_ID_UNAVAILABLE_ERROR)
}
const oauthClient = new MetaOauthClient(logger)
setIntegrationIdentifier(wabaId)
await oauthClient.registerNumber(defaultBotPhoneNumberId, accessToken)
await oauthClient.subscribeToWebhooks(wabaId, accessToken)
return responses.displayButtons({
pageTitle: 'Configuration Complete',
htmlOrMarkdownPageContents: `
Your configuration is now complete and the selected WhatsApp number will start answering as this bot, you can add the number to your personal contacts and test it.
**Here are some things to verify if you are unable to talk with your bot on WhatsApp**
- Confirm if you added the correct number (With country and area code)
- Double check if you published this bot
- Wait a few hours (3-4) for Meta to process the Setup
- Verify if your display name was not denied by Meta (you will get an email in the Facebook accounts email address)`,
buttons: [{ label: 'Okay', buttonType: 'primary', action: 'navigate', navigateToStep: 'finish-wrap-up' }],
})
}
const _endHandler: WizardHandler = ({ responses }) => {
return responses.endWizard({
success: true,
})
}
const _doStepFinishWrapUp: WizardHandler = async (props) => {
const { responses } = props
return responses.redirectToExternalUrl(oauthWizard.getInterstitialUrl(true).toString())
}
const _getOAuthRedirectUri = (ctx?: bp.Context) => oauthWizard.getWizardStepUrl('get-access-token', ctx).toString()
// client.patchState is not working correctly
const _patchCredentialsState = async (
client: bp.Client,
ctx: bp.Context,
newState: Partial<typeof bp.states.credentials>
) => {
const currentState = await _getCredentialsState(client, ctx)
await client.setState({
type: 'integration',
name: 'credentials',
id: ctx.integrationId,
payload: {
...currentState,
...newState,
},
})
}
const _getCredentialsState = async (client: bp.Client, ctx: bp.Context) => {
try {
return (
(
await client.getState({
type: 'integration',
name: 'credentials',
id: ctx.integrationId,
})
)?.state?.payload || {}
)
} catch {
return {}
}
}
@@ -0,0 +1,93 @@
import { WhatsAppReactionMessage } from 'src/misc/types'
import { getMessageFromWhatsappMessageId } from 'src/misc/util'
import * as bp from '.botpress'
type Message = NonNullable<Awaited<ReturnType<typeof getMessageFromWhatsappMessageId>>>
type ReactionEventTypes = 'reactionAdded' | 'reactionRemoved'
type CommonReactionHandlerProps = {
message: Message
reactionEventType: ReactionEventTypes
userId: string
newReactionTagValue: string | undefined
eventReaction: string
} & bp.HandlerProps
export const reactionHandler = async (reactionMessage: WhatsAppReactionMessage, props: bp.HandlerProps) => {
const { client, logger } = props
logger.forBot().debug('Received reaction message')
const { message_id: messageId, emoji: currentReaction } = reactionMessage.reaction
const message = await getMessageFromWhatsappMessageId(messageId, client)
if (!message) {
logger.forBot().warn('No associated message found for reaction, ignoring reaction')
return
}
// Prefer the legacy phone identity so existing users keep matching; fall back to the stable
// user_id only for opted-in users with no phone number.
const reactingUserId = reactionMessage.from ?? reactionMessage.from_user_id
if (!reactingUserId) {
logger.forBot().warn('No user identifier found on reaction message, ignoring reaction')
return
}
const previousReaction = message.tags.reaction
const reactionHasChanged = currentReaction !== previousReaction
if (previousReaction && reactionHasChanged) {
await _handleReaction({
message,
reactionEventType: 'reactionRemoved',
userId: reactingUserId,
newReactionTagValue: undefined,
eventReaction: previousReaction,
...props,
})
}
if (currentReaction && reactionHasChanged) {
await _handleReaction({
message,
reactionEventType: 'reactionAdded',
userId: reactingUserId,
newReactionTagValue: currentReaction,
eventReaction: currentReaction,
...props,
})
}
}
const _handleReaction = async ({
message,
reactionEventType,
userId,
newReactionTagValue,
eventReaction,
client,
logger,
}: CommonReactionHandlerProps) => {
logger.forBot().debug(`Sending reaction event of type ${reactionEventType}: ${eventReaction}`)
await client.updateMessage({
id: message.id,
tags: {
reaction: newReactionTagValue ?? '', // Empty string removes the tag
},
})
const { user } = await client.getOrCreateUser({
tags: {
userId,
},
})
await client.createEvent({
type: reactionEventType,
payload: {
messageId: message.id,
reaction: eventReaction,
conversationId: message.conversationId,
userId: user.id,
},
messageId: message.id,
conversationId: message.conversationId,
userId: user.id,
})
}
@@ -0,0 +1,88 @@
import {
CONVERSATION_CONNECTED_MESSAGE,
CONVERSATION_DISCONNECTED_MESSAGE,
extractSandboxCommand,
} from '@botpress/common'
import { getAuthenticatedWhatsappClient } from 'src/auth'
import { WhatsAppPayloadSchema, WhatsAppMessageValue } from 'src/misc/types'
import { Text } from 'whatsapp-api-js/messages'
import * as bp from '.botpress'
export const isSandboxCommand = (props: bp.HandlerProps): boolean => {
const { req } = props
return extractSandboxCommand(req) !== undefined
}
const NO_MESSAGE_ERROR = { status: 400, body: 'No message found in request' } as const
const NO_PHONE_ERROR = { status: 400, body: 'No phone number found on message' } as const
export const sandboxHandler: bp.IntegrationProps['handler'] = async (props: bp.HandlerProps) => {
const { req } = props
const command = extractSandboxCommand(req)
if (!command) {
return { status: 400, body: 'No sandbox command to handle' }
}
if (command === 'join') {
return await _handleJoinCommand(props)
} else if (command === 'leave') {
return await _handleLeaveCommand(props)
}
return
}
const _handleJoinCommand = async (props: bp.HandlerProps) => {
const { client, ctx } = props
const value = _extractValueFromRequest(props)
const message = value?.messages?.[0]
if (!message) {
return NO_MESSAGE_ERROR
}
const userPhoneNumber = message.from
if (!userPhoneNumber) {
return NO_PHONE_ERROR
}
const botPhoneNumberId = value.metadata.phone_number_id
const whatsapp = await getAuthenticatedWhatsappClient(client, ctx)
await whatsapp.markAsRead(botPhoneNumberId, message.id, 'text')
await whatsapp.sendMessage(botPhoneNumberId, userPhoneNumber, new Text(CONVERSATION_CONNECTED_MESSAGE))
return
}
const _handleLeaveCommand = async (props: bp.HandlerProps) => {
const { client, ctx } = props
const value = _extractValueFromRequest(props)
const message = value?.messages?.[0]
if (!message) {
return NO_MESSAGE_ERROR
}
const userPhoneNumber = message.from
if (!userPhoneNumber) {
return NO_PHONE_ERROR
}
const botPhoneNumberId = value.metadata.phone_number_id
const whatsapp = await getAuthenticatedWhatsappClient(client, ctx)
await whatsapp.markAsRead(botPhoneNumberId, message.id, 'text')
await whatsapp.sendMessage(botPhoneNumberId, userPhoneNumber, new Text(CONVERSATION_DISCONNECTED_MESSAGE))
return
}
const _extractValueFromRequest = (props: bp.HandlerProps): WhatsAppMessageValue | undefined => {
const { req, logger } = props
if (!req.body) {
return undefined
}
try {
const data = JSON.parse(req.body)
const payload = WhatsAppPayloadSchema.parse(data)
return payload.entry[0]?.changes[0]?.field === 'messages' ? payload.entry[0]?.changes[0]?.value : undefined
} catch (e: any) {
logger.error('Error while extracting message from request:', e?.message ?? '[unknown error]')
return undefined
}
}
@@ -0,0 +1,133 @@
import { getAuthenticatedWhatsappClient } from 'src/auth'
import { getMessageFromWhatsappMessageId } from 'src/misc/util'
import { Text } from 'whatsapp-api-js/messages'
import { WhatsAppStatusValue } from '../../misc/types'
import * as bp from '.botpress'
// Meta error codes for which a plain-text URL fallback is useful.
// 131053 = Media upload error (Meta rejected the media format/codec).
// 131052 = Media download error (Meta couldn't fetch the URL).
const MEDIA_FAILURE_CODES = new Set([131052, 131053])
// WhatsApp status progression: SENT → DELIVERED → READ. FAILED is terminal.
const STATUS_RANK: Record<string, number> = {
SENT: 1,
DELIVERED: 2,
READ: 3,
FAILED: 4,
}
const _isDuplicateOrStale = (incoming: WhatsAppStatusValue['status'], existing: string | undefined): boolean => {
const incomingRank = STATUS_RANK[incoming.toUpperCase()] ?? 0
const existingRank = STATUS_RANK[(existing ?? '').toUpperCase()] ?? 0
return existingRank >= incomingRank
}
const _getMediaUrlFromPayload = (
message: { type: string; payload: { [k: string]: any } } | undefined
): string | undefined => {
if (!message) return undefined
switch (message.type) {
case 'audio':
return message.payload.audioUrl
case 'video':
return message.payload.videoUrl
case 'image':
return message.payload.imageUrl
case 'file':
return message.payload.fileUrl
default:
return undefined
}
}
export const statusHandler = async (value: WhatsAppStatusValue, props: bp.HandlerProps) => {
const { client, ctx, logger } = props
const message = await getMessageFromWhatsappMessageId(value.id, client)
if (!message) {
logger
.forBot()
.debug(
`Received WhatsApp "${value.status}" webhook, but there is no corresponding message in Botpress for WhatsApp message ID: ${value.id}`
)
return
}
const isStale = _isDuplicateOrStale(value.status, message.tags.status)
if (!isStale) {
await client.updateMessage({ id: message.id, tags: { status: value.status.toUpperCase() } })
}
switch (value.status) {
case 'sent':
await client.createEvent({
type: 'messageSent',
conversationId: message.conversationId,
messageId: message.id,
payload: {},
})
break
case 'delivered':
await client.createEvent({
type: 'messageDelivered',
conversationId: message.conversationId,
messageId: message.id,
payload: {},
})
break
case 'read':
await client.createEvent({
type: 'messageRead',
conversationId: message.conversationId,
messageId: message.id,
payload: {},
})
break
case 'failed': {
const errorDetails =
value.errors
?.map(
(err) =>
`${err.title} (${err.code}): ${err.message}${err.error_data?.details ? ` - ${err.error_data.details}` : ''}`
)
.join('; ') || 'Unknown error'
logger
.forBot()
.error(
`WhatsApp message delivery failed. Message ID: ${value.id}, Recipient: ${value.recipient_id}, Errors: ${errorDetails}`
)
const mediaErrorCode = value.errors?.find((e) => MEDIA_FAILURE_CODES.has(e.code))?.code
const mediaUrl = _getMediaUrlFromPayload(message)
if (!isStale && mediaErrorCode && mediaUrl) {
try {
const { conversation } = await client.getConversation({ id: message.conversationId })
const { botPhoneNumberId, userPhone } = conversation.tags
if (botPhoneNumberId && userPhone) {
logger
.forBot()
.warn(
`WhatsApp rejected the ${message.type} (code ${mediaErrorCode}); sending the URL as a plain text fallback to ${userPhone}.`
)
const whatsapp = await getAuthenticatedWhatsappClient(client, ctx)
await whatsapp.sendMessage(botPhoneNumberId, userPhone, new Text(mediaUrl))
}
} catch (err) {
logger.forBot().error('Failed to send the plain text URL fallback for the rejected media:', err)
}
}
await client.createEvent({
type: 'messageFailed',
conversationId: message.conversationId,
messageId: message.id,
payload: {
error: errorDetails,
},
})
break
}
}
}
@@ -0,0 +1,27 @@
import { getVerifyToken } from 'src/auth'
import * as bp from '.botpress'
export const subscribeHandler: bp.IntegrationProps['handler'] = async (props: bp.HandlerProps) => {
const { req, ctx } = props
const queryParams = new URLSearchParams(req.query)
const mode = queryParams.get('hub.mode')
if (mode !== 'subscribe') {
return { status: 400, body: "Mode should be set to 'subscribe'" }
}
const token = queryParams.get('hub.verify_token')
const challenge = queryParams.get('hub.challenge')
if (!token || !challenge) {
return { status: 400, body: 'Missing required query parameters' }
}
if (token !== getVerifyToken(ctx)) {
return { status: 403, body: 'Invalid verify token' }
}
return {
status: 200,
body: challenge,
}
}
@@ -0,0 +1 @@
export { handler } from './handler'
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact",
"paths": { "*": ["./*"] },
"outDir": "dist",
"types": ["preact"]
},
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
}
+2
View File
@@ -0,0 +1,2 @@
import config from '../../vitest.config'
export default config