chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"strictness": 1
|
||||
}
|
||||
@@ -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')
|
||||
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
metaId = null
|
||||
body = parse_json!(.body)
|
||||
if body.object == "whatsapp_business_account" {
|
||||
metaId = body.entry[0].id
|
||||
}
|
||||
metaId
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
```
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,4 @@
|
||||
webhookId = to_string!(.webhookId)
|
||||
webhookUrl = to_string!(.webhookUrl)
|
||||
|
||||
"{{ webhookUrl }}/oauth/wizard/start-confirm?state={{ webhookId }}"
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 24–26
|
||||
const height = 1 + buffer.readUIntLE(27, 3) // 3 bytes: offset 27–29
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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 = ''
|
||||
const expected = 'Image: Alt text (https://example.com/image.jpg)'
|
||||
expect(convertMarkdownToWhatsApp(input)).toBe(expected)
|
||||
})
|
||||
|
||||
it('should convert images without alt text', () => {
|
||||
const input = ''
|
||||
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 & entities'
|
||||
const expected = 'Text with & 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  [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:  -> 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()]
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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' }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist",
|
||||
"types": ["preact"]
|
||||
},
|
||||
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import config from '../../vitest.config'
|
||||
export default config
|
||||
Reference in New Issue
Block a user