From bb8d58279d1d954d4a316f62b00edf44af537d2e Mon Sep 17 00:00:00 2001 From: Nathaniel Girard <72364963+Nathaniel-Girard@users.noreply.github.com> Date: Mon, 11 May 2026 10:16:10 -0400 Subject: [PATCH 1/3] chore: update api (#15181) --- package.json | 2 +- packages/cli/package.json | 6 +-- packages/cli/templates/empty-bot/package.json | 4 +- .../templates/empty-integration/package.json | 4 +- .../cli/templates/empty-plugin/package.json | 2 +- .../cli/templates/hello-world/package.json | 4 +- .../templates/webhook-message/package.json | 4 +- packages/client/package.json | 2 +- packages/llmz/package.json | 4 +- packages/sdk/package.json | 4 +- packages/vai/package.json | 4 +- pnpm-lock.yaml | 38 +++++++++---------- 12 files changed, 39 insertions(+), 39 deletions(-) diff --git a/package.json b/package.json index f9b5d4aa381..a51f22af3da 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ }, "devDependencies": { "@aws-sdk/client-dynamodb": "^3.564.0", - "@botpress/api": "1.102.0", + "@botpress/api": "1.105.0", "@botpress/cli": "workspace:*", "@botpress/client": "workspace:*", "@botpress/sdk": "workspace:*", diff --git a/packages/cli/package.json b/packages/cli/package.json index 29dc81ff9a8..f4e6650bac8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@botpress/cli", - "version": "6.7.1", + "version": "6.8.0", "description": "Botpress CLI", "scripts": { "build": "pnpm run build:types && pnpm run bundle && pnpm run template:gen", @@ -27,8 +27,8 @@ "dependencies": { "@apidevtools/json-schema-ref-parser": "^11.7.0", "@botpress/chat": "0.5.5", - "@botpress/client": "1.44.0", - "@botpress/sdk": "6.9.2", + "@botpress/client": "1.45.0", + "@botpress/sdk": "6.10.0", "@bpinternal/const": "^0.1.0", "@bpinternal/tunnel": "^0.1.1", "@bpinternal/verel": "^0.2.0", diff --git a/packages/cli/templates/empty-bot/package.json b/packages/cli/templates/empty-bot/package.json index dac2c254069..ff2fc419bda 100644 --- a/packages/cli/templates/empty-bot/package.json +++ b/packages/cli/templates/empty-bot/package.json @@ -5,8 +5,8 @@ }, "private": true, "dependencies": { - "@botpress/client": "1.44.0", - "@botpress/sdk": "6.9.2" + "@botpress/client": "1.45.0", + "@botpress/sdk": "6.10.0" }, "devDependencies": { "@types/node": "^22.16.4", diff --git a/packages/cli/templates/empty-integration/package.json b/packages/cli/templates/empty-integration/package.json index 0bfb224857e..ecbdbfa325f 100644 --- a/packages/cli/templates/empty-integration/package.json +++ b/packages/cli/templates/empty-integration/package.json @@ -6,8 +6,8 @@ }, "private": true, "dependencies": { - "@botpress/client": "1.44.0", - "@botpress/sdk": "6.9.2" + "@botpress/client": "1.45.0", + "@botpress/sdk": "6.10.0" }, "devDependencies": { "@types/node": "^22.16.4", diff --git a/packages/cli/templates/empty-plugin/package.json b/packages/cli/templates/empty-plugin/package.json index 73daed22f63..8a61867941f 100644 --- a/packages/cli/templates/empty-plugin/package.json +++ b/packages/cli/templates/empty-plugin/package.json @@ -6,7 +6,7 @@ }, "private": true, "dependencies": { - "@botpress/sdk": "6.9.2" + "@botpress/sdk": "6.10.0" }, "devDependencies": { "@types/node": "^22.16.4", diff --git a/packages/cli/templates/hello-world/package.json b/packages/cli/templates/hello-world/package.json index 1039821dffd..fb168db7857 100644 --- a/packages/cli/templates/hello-world/package.json +++ b/packages/cli/templates/hello-world/package.json @@ -6,8 +6,8 @@ }, "private": true, "dependencies": { - "@botpress/client": "1.44.0", - "@botpress/sdk": "6.9.2" + "@botpress/client": "1.45.0", + "@botpress/sdk": "6.10.0" }, "devDependencies": { "@types/node": "^22.16.4", diff --git a/packages/cli/templates/webhook-message/package.json b/packages/cli/templates/webhook-message/package.json index a3020d7f67e..2d857b24ba1 100644 --- a/packages/cli/templates/webhook-message/package.json +++ b/packages/cli/templates/webhook-message/package.json @@ -6,8 +6,8 @@ }, "private": true, "dependencies": { - "@botpress/client": "1.44.0", - "@botpress/sdk": "6.9.2", + "@botpress/client": "1.45.0", + "@botpress/sdk": "6.10.0", "axios": "^1.6.8" }, "devDependencies": { diff --git a/packages/client/package.json b/packages/client/package.json index 4f88c8880a6..f278a46dcfa 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@botpress/client", - "version": "1.44.0", + "version": "1.45.0", "description": "Botpress Client", "main": "./dist/index.cjs", "module": "./dist/index.mjs", diff --git a/packages/llmz/package.json b/packages/llmz/package.json index db6413aa54b..8daec3f60c6 100644 --- a/packages/llmz/package.json +++ b/packages/llmz/package.json @@ -2,7 +2,7 @@ "name": "llmz", "type": "module", "description": "LLMz - An LLM-native Typescript VM built on top of Zui", - "version": "0.0.75", + "version": "0.0.76", "types": "./dist/index.d.ts", "main": "./dist/index.cjs", "module": "./dist/index.js", @@ -71,7 +71,7 @@ "tsx": "^4.19.2" }, "peerDependencies": { - "@botpress/client": "1.44.0", + "@botpress/client": "1.45.0", "@botpress/cognitive": "0.5.3", "@bpinternal/thicktoken": "^2.0.0", "@bpinternal/zui": "^2.1.1" diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 1f317087439..d98d71f0e39 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@botpress/sdk", - "version": "6.9.2", + "version": "6.10.0", "description": "Botpress SDK", "main": "./dist/index.cjs", "module": "./dist/index.mjs", @@ -20,7 +20,7 @@ "author": "", "license": "MIT", "dependencies": { - "@botpress/client": "1.44.0", + "@botpress/client": "1.45.0", "browser-or-node": "^2.1.1", "semver": "^7.3.8" }, diff --git a/packages/vai/package.json b/packages/vai/package.json index b35494bf201..3d2c5b2ac39 100644 --- a/packages/vai/package.json +++ b/packages/vai/package.json @@ -1,6 +1,6 @@ { "name": "@botpress/vai", - "version": "0.0.33", + "version": "0.0.34", "description": "Vitest AI (vai) – a vitest extension for testing with LLMs", "types": "./dist/index.d.ts", "exports": { @@ -40,7 +40,7 @@ "tsup": "^8.0.2" }, "peerDependencies": { - "@botpress/client": "1.44.0", + "@botpress/client": "1.45.0", "@bpinternal/thicktoken": "^1.0.1", "@bpinternal/zui": "^2.1.1", "lodash": "^4.17.21", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7c580f056c..9a506c9a504 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,8 +17,8 @@ importers: specifier: ^3.564.0 version: 3.709.0 '@botpress/api': - specifier: 1.102.0 - version: 1.102.0 + specifier: 1.105.0 + version: 1.105.0 '@botpress/cli': specifier: workspace:* version: link:packages/cli @@ -2686,10 +2686,10 @@ importers: specifier: 0.5.5 version: link:../chat-client '@botpress/client': - specifier: 1.44.0 + specifier: 1.45.0 version: link:../client '@botpress/sdk': - specifier: 6.9.2 + specifier: 6.10.0 version: link:../sdk '@bpinternal/const': specifier: ^0.1.0 @@ -2810,10 +2810,10 @@ importers: packages/cli/templates/empty-bot: dependencies: '@botpress/client': - specifier: 1.44.0 + specifier: 1.45.0 version: link:../../../client '@botpress/sdk': - specifier: 6.9.2 + specifier: 6.10.0 version: link:../../../sdk devDependencies: '@types/node': @@ -2826,10 +2826,10 @@ importers: packages/cli/templates/empty-integration: dependencies: '@botpress/client': - specifier: 1.44.0 + specifier: 1.45.0 version: link:../../../client '@botpress/sdk': - specifier: 6.9.2 + specifier: 6.10.0 version: link:../../../sdk devDependencies: '@types/node': @@ -2842,7 +2842,7 @@ importers: packages/cli/templates/empty-plugin: dependencies: '@botpress/sdk': - specifier: 6.9.2 + specifier: 6.10.0 version: link:../../../sdk devDependencies: '@types/node': @@ -2855,10 +2855,10 @@ importers: packages/cli/templates/hello-world: dependencies: '@botpress/client': - specifier: 1.44.0 + specifier: 1.45.0 version: link:../../../client '@botpress/sdk': - specifier: 6.9.2 + specifier: 6.10.0 version: link:../../../sdk devDependencies: '@types/node': @@ -2871,10 +2871,10 @@ importers: packages/cli/templates/webhook-message: dependencies: '@botpress/client': - specifier: 1.44.0 + specifier: 1.45.0 version: link:../../../client '@botpress/sdk': - specifier: 6.9.2 + specifier: 6.10.0 version: link:../../../sdk axios: specifier: ^1.6.8 @@ -3025,7 +3025,7 @@ importers: specifier: ^7.26.3 version: 7.26.9 '@botpress/client': - specifier: 1.44.0 + specifier: 1.45.0 version: link:../client '@botpress/cognitive': specifier: 0.5.3 @@ -3131,7 +3131,7 @@ importers: packages/sdk: dependencies: '@botpress/client': - specifier: 1.44.0 + specifier: 1.45.0 version: link:../client '@bpinternal/zui': specifier: ^2.1.1 @@ -3168,7 +3168,7 @@ importers: packages/vai: dependencies: '@botpress/client': - specifier: 1.44.0 + specifier: 1.45.0 version: link:../client '@bpinternal/thicktoken': specifier: ^1.0.1 @@ -4198,8 +4198,8 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} - '@botpress/api@1.102.0': - resolution: {integrity: sha512-Qce4ZguplKIDAQJxkXwgHVLiHSm36nCsuLV4SbPvKPgBtGDOGINKbpimgHkfDhW7DCfMUmS2W0gWew/LHFqbpA==} + '@botpress/api@1.105.0': + resolution: {integrity: sha512-DnnqYlEyLCXf7cCaPSJvrQHhb+ycRbFPCyCGKTn0y7mvF32n5tgwZUbC4fEtHt/i6u9AuXPG3ObqPjPcYxocLw==} '@bpinternal/const@0.1.0': resolution: {integrity: sha512-iIQg9oYYXOt+LSK34oNhJVQTcgRdtLmLZirEUaE+R9hnmbKONA5reR2kTewxZmekGyxej+5RtDK9xrC/0hmeAw==} @@ -14001,7 +14001,7 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} - '@botpress/api@1.102.0': + '@botpress/api@1.105.0': dependencies: '@bpinternal/opapi': 1.0.0(openapi-types@12.1.3) transitivePeerDependencies: From 805e71b63d6f78291c6956e62aa36894b4289b2e Mon Sep 17 00:00:00 2001 From: Pascal B <179493770+pascal-botpress@users.noreply.github.com> Date: Mon, 11 May 2026 13:08:17 -0400 Subject: [PATCH 2/3] feat(sdk): surface log level and identity fields in JSON logs (#15175) --- packages/cli/package.json | 2 +- packages/cli/templates/empty-bot/package.json | 2 +- .../cli/templates/empty-integration/package.json | 2 +- packages/cli/templates/empty-plugin/package.json | 2 +- packages/cli/templates/hello-world/package.json | 2 +- packages/cli/templates/webhook-message/package.json | 2 +- packages/sdk/package.json | 2 +- packages/sdk/src/base-logger.ts | 12 ++++++------ packages/sdk/src/integration/server/index.ts | 7 ++++++- .../sdk/src/integration/server/integration-logger.ts | 11 +++++++++-- pnpm-lock.yaml | 12 ++++++------ 11 files changed, 34 insertions(+), 22 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index f4e6650bac8..6a472a1ede2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -28,7 +28,7 @@ "@apidevtools/json-schema-ref-parser": "^11.7.0", "@botpress/chat": "0.5.5", "@botpress/client": "1.45.0", - "@botpress/sdk": "6.10.0", + "@botpress/sdk": "6.11.0", "@bpinternal/const": "^0.1.0", "@bpinternal/tunnel": "^0.1.1", "@bpinternal/verel": "^0.2.0", diff --git a/packages/cli/templates/empty-bot/package.json b/packages/cli/templates/empty-bot/package.json index ff2fc419bda..717c6f44384 100644 --- a/packages/cli/templates/empty-bot/package.json +++ b/packages/cli/templates/empty-bot/package.json @@ -6,7 +6,7 @@ "private": true, "dependencies": { "@botpress/client": "1.45.0", - "@botpress/sdk": "6.10.0" + "@botpress/sdk": "6.11.0" }, "devDependencies": { "@types/node": "^22.16.4", diff --git a/packages/cli/templates/empty-integration/package.json b/packages/cli/templates/empty-integration/package.json index ecbdbfa325f..f6ff153d0b8 100644 --- a/packages/cli/templates/empty-integration/package.json +++ b/packages/cli/templates/empty-integration/package.json @@ -7,7 +7,7 @@ "private": true, "dependencies": { "@botpress/client": "1.45.0", - "@botpress/sdk": "6.10.0" + "@botpress/sdk": "6.11.0" }, "devDependencies": { "@types/node": "^22.16.4", diff --git a/packages/cli/templates/empty-plugin/package.json b/packages/cli/templates/empty-plugin/package.json index 8a61867941f..6f0d2b1c9af 100644 --- a/packages/cli/templates/empty-plugin/package.json +++ b/packages/cli/templates/empty-plugin/package.json @@ -6,7 +6,7 @@ }, "private": true, "dependencies": { - "@botpress/sdk": "6.10.0" + "@botpress/sdk": "6.11.0" }, "devDependencies": { "@types/node": "^22.16.4", diff --git a/packages/cli/templates/hello-world/package.json b/packages/cli/templates/hello-world/package.json index fb168db7857..952ea87210b 100644 --- a/packages/cli/templates/hello-world/package.json +++ b/packages/cli/templates/hello-world/package.json @@ -7,7 +7,7 @@ "private": true, "dependencies": { "@botpress/client": "1.45.0", - "@botpress/sdk": "6.10.0" + "@botpress/sdk": "6.11.0" }, "devDependencies": { "@types/node": "^22.16.4", diff --git a/packages/cli/templates/webhook-message/package.json b/packages/cli/templates/webhook-message/package.json index 2d857b24ba1..60192963855 100644 --- a/packages/cli/templates/webhook-message/package.json +++ b/packages/cli/templates/webhook-message/package.json @@ -7,7 +7,7 @@ "private": true, "dependencies": { "@botpress/client": "1.45.0", - "@botpress/sdk": "6.10.0", + "@botpress/sdk": "6.11.0", "axios": "^1.6.8" }, "devDependencies": { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index d98d71f0e39..d738cdeda5c 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@botpress/sdk", - "version": "6.10.0", + "version": "6.11.0", "description": "Botpress SDK", "main": "./dist/index.cjs", "module": "./dist/index.mjs", diff --git a/packages/sdk/src/base-logger.ts b/packages/sdk/src/base-logger.ts index f0b1819c1ea..32ed8480c20 100644 --- a/packages/sdk/src/base-logger.ts +++ b/packages/sdk/src/base-logger.ts @@ -1,6 +1,6 @@ import util from 'util' -type LogLevel = 'info' | 'debug' | 'warn' | 'error' +export type LogLevel = 'info' | 'debug' | 'warn' | 'error' export type IssueLogEvent = { type: 'issue' @@ -44,20 +44,20 @@ export abstract class BaseLogger { } private _log(level: LogLevel, args: Parameters) { - this._getConsoleMethod(level)(this._serializeMessage(args)) + this._getConsoleMethod(level)(this._serializeMessage(level, args)) } - private _serializeMessage(args: Parameters) { + private _serializeMessage(level: LogLevel, args: Parameters) { const msg = util.format(...args) if (process.env['BP_LOG_FORMAT'] === 'json') { - return this.getJsonMessage(msg) + return this.getJsonMessage(level, msg) } else { return msg } } - protected getJsonMessage(msg: string) { - return JSON.stringify({ msg, options: this.defaultOptions }) + protected getJsonMessage(level: LogLevel, msg: string) { + return JSON.stringify({ msg, level, options: this.defaultOptions }) } private _getConsoleMethod(level: LogLevel): (...args: unknown[]) => void { diff --git a/packages/sdk/src/integration/server/index.ts b/packages/sdk/src/integration/server/index.ts index cb81bb573c2..4d1602a606d 100644 --- a/packages/sdk/src/integration/server/index.ts +++ b/packages/sdk/src/integration/server/index.ts @@ -51,7 +51,12 @@ const getServerProps = ( headers: instance.managesOwnTracePropagation ? {} : extractTracingHeaders(req.headers), }) const client = new IntegrationSpecificClient(vanillaClient) - const logger = new IntegrationLogger({ traceId }) + const logger = new IntegrationLogger({ + traceId, + botId: ctx.botId, + integrationId: ctx.integrationId, + integrationAlias: ctx.integrationAlias, + }) return { ctx, diff --git a/packages/sdk/src/integration/server/integration-logger.ts b/packages/sdk/src/integration/server/integration-logger.ts index d873add181f..da9c3949cc7 100644 --- a/packages/sdk/src/integration/server/integration-logger.ts +++ b/packages/sdk/src/integration/server/integration-logger.ts @@ -1,6 +1,9 @@ -import { BaseLogger } from '../../base-logger' +import { BaseLogger, type LogLevel } from '../../base-logger' type IntegrationLogOptions = { + botId?: string + integrationId?: string + integrationAlias?: string userId?: string conversationId?: string traceId?: string @@ -71,9 +74,13 @@ export class IntegrationLogger extends BaseLogger { }) } - protected override getJsonMessage(msg: string) { + protected override getJsonMessage(level: LogLevel, msg: string) { return JSON.stringify({ msg, + level, + botId: this.defaultOptions.botId, + integrationId: this.defaultOptions.integrationId, + integrationAlias: this.defaultOptions.integrationAlias, //We need to have snake case 'visible_to_bot_owner' since that is how we used to differentiate between bot and integration logs visible_to_bot_owner: this.defaultOptions.visibleToBotOwners, hidden_to_integration_owner: this.defaultOptions.hiddenToIntegrationOwners, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a506c9a504..c555841b02e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2689,7 +2689,7 @@ importers: specifier: 1.45.0 version: link:../client '@botpress/sdk': - specifier: 6.10.0 + specifier: 6.11.0 version: link:../sdk '@bpinternal/const': specifier: ^0.1.0 @@ -2813,7 +2813,7 @@ importers: specifier: 1.45.0 version: link:../../../client '@botpress/sdk': - specifier: 6.10.0 + specifier: 6.11.0 version: link:../../../sdk devDependencies: '@types/node': @@ -2829,7 +2829,7 @@ importers: specifier: 1.45.0 version: link:../../../client '@botpress/sdk': - specifier: 6.10.0 + specifier: 6.11.0 version: link:../../../sdk devDependencies: '@types/node': @@ -2842,7 +2842,7 @@ importers: packages/cli/templates/empty-plugin: dependencies: '@botpress/sdk': - specifier: 6.10.0 + specifier: 6.11.0 version: link:../../../sdk devDependencies: '@types/node': @@ -2858,7 +2858,7 @@ importers: specifier: 1.45.0 version: link:../../../client '@botpress/sdk': - specifier: 6.10.0 + specifier: 6.11.0 version: link:../../../sdk devDependencies: '@types/node': @@ -2874,7 +2874,7 @@ importers: specifier: 1.45.0 version: link:../../../client '@botpress/sdk': - specifier: 6.10.0 + specifier: 6.11.0 version: link:../../../sdk axios: specifier: ^1.6.8 From e352bb2d5271d5c2c744ab71825070cb6de2e04f Mon Sep 17 00:00:00 2001 From: David Vitor Antonio Date: Mon, 11 May 2026 14:20:54 -0300 Subject: [PATCH 3/3] feat(whatsapp): add native Carousels and Cards (#15176) --- integrations/whatsapp/hub.md | 27 +++ .../whatsapp/integration.definition.ts | 2 +- integrations/whatsapp/src/channels/channel.ts | 6 +- .../src/channels/message-types/card.ts | 205 ++++++++++++------ .../src/channels/message-types/carousel.ts | 183 +++++++++++++++- .../message-types/interactive/button.ts | 2 +- .../channels/message-types/raw-interactive.ts | 34 +++ 7 files changed, 382 insertions(+), 77 deletions(-) create mode 100644 integrations/whatsapp/src/channels/message-types/raw-interactive.ts diff --git a/integrations/whatsapp/hub.md b/integrations/whatsapp/hub.md index 6460e7e04c8..3b6b75aea94 100644 --- a/integrations/whatsapp/hub.md +++ b/integrations/whatsapp/hub.md @@ -2,6 +2,33 @@ The WhatsApp integration allows your AI-powered chatbot to seamlessly connect with WhatsApp, one of the most popular messaging platforms worldwide. Integrate your chatbot with WhatsApp to engage with your audience, automate conversations, and provide instant support. With this integration, you can send messages, handle inquiries, deliver notifications, and perform actions directly within WhatsApp. Leverage WhatsApp's powerful features such as text messages, media sharing, document sharing, and more to create personalized and interactive chatbot experiences. Connect with users on a platform they already use and enhance customer engagement with the WhatsApp Integration for Botpress. +## Card and carousel rendering + +WhatsApp has no direct equivalent of Botpress's `card` and `carousel` types. The integration maps each to a native WhatsApp message type: + +- `postback` / `say` actions → [Reply Buttons](https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/interactive-reply-buttons-messages) (up to 3 per bubble) +- `url` actions → [Interactive CTA URL](https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/interactive-cta-url-messages) (one URL button per bubble) +- A group of cards meeting the carousel rules → Interactive Media Carousel + +### Cards + +A card renders as one or more bubbles in original action order: + +- The first bubble carries the image as a header and the title+subtitle together as the body; later bubbles are minimal. +- More than 3 postback/say buttons are split across multiple bubbles (3 per bubble). +- Multiple URL actions each become their own CTA bubble. + +### Carousel + +A `carousel` becomes one or more native Carousels (up to 10 cards each) when every card: + +- has an `imageUrl`, +- has exactly one `url` action OR 1-2 `postback`/`say` actions (no mixing), +- has a combined title + subtitle body of 160 characters or fewer, and +- has quick-reply values unique across the carousel. + +Cards with the same shape are grouped together (up to 10 per group). If any condition fails, or if grouping would leave a single card on its own, the whole carousel renders per-card instead and a warning is logged with the reason. + ## Migrating from 3.x to 4.x ### Automatic downloading of media files diff --git a/integrations/whatsapp/integration.definition.ts b/integrations/whatsapp/integration.definition.ts index a1a97221130..7f9cc3d9f6a 100644 --- a/integrations/whatsapp/integration.definition.ts +++ b/integrations/whatsapp/integration.definition.ts @@ -157,7 +157,7 @@ const defaultBotPhoneNumberId = { } export const INTEGRATION_NAME = 'whatsapp' -export const INTEGRATION_VERSION = '4.14.0' +export const INTEGRATION_VERSION = '4.15.0' export default new IntegrationDefinition({ name: INTEGRATION_NAME, version: INTEGRATION_VERSION, diff --git a/integrations/whatsapp/src/channels/channel.ts b/integrations/whatsapp/src/channels/channel.ts index 2f34ec3d41b..7df116a36d3 100644 --- a/integrations/whatsapp/src/channels/channel.ts +++ b/integrations/whatsapp/src/channels/channel.ts @@ -24,6 +24,7 @@ import * as carousel from './message-types/carousel' import * as choice from './message-types/choice' import * as dropdown from './message-types/dropdown' import * as image from './message-types/image' +import { RawInteractiveMessage } from './message-types/raw-interactive' import * as bp from '.botpress' const PART_DELAY_MS = 1000 @@ -114,8 +115,8 @@ export const channel: bp.IntegrationProps['channels']['channel'] = { message: new Location(payload.longitude, payload.latitude), }) }, - carousel: async ({ payload, logger, ...props }) => { - await _sendMany({ ...props, logger, generator: carousel.generateOutgoingMessages(payload, logger) }) + carousel: async (props) => { + await _sendMany({ ...props, generator: carousel.generateOutgoingMessages(props) }) }, card: async ({ payload, logger, ...props }) => { await _sendMany({ ...props, logger, generator: card.generateOutgoingMessages(payload, logger) }) @@ -229,6 +230,7 @@ type OutgoingMessage = | Interactive | Template | Reaction + | RawInteractiveMessage type SendMessageProps = { client: bp.Client diff --git a/integrations/whatsapp/src/channels/message-types/card.ts b/integrations/whatsapp/src/channels/message-types/card.ts index e69272a8a1a..4ace17b3523 100644 --- a/integrations/whatsapp/src/channels/message-types/card.ts +++ b/integrations/whatsapp/src/channels/message-types/card.ts @@ -1,15 +1,30 @@ -import { Text, Interactive, ActionButtons, Header, Image, Button, ActionCTA } from 'whatsapp-api-js/messages' +import { Text, Interactive, ActionButtons, ActionCTA, Header, Image, Body, Button } from 'whatsapp-api-js/messages' import { WHATSAPP } from '../../misc/constants' import { convertMarkdownToWhatsApp } from '../../misc/markdown-to-whatsapp-rtf' import { chunkArray, hasAtleastOne } from '../../misc/util' -import * as body from './interactive/body' import * as button from './interactive/button' -import * as footer from './interactive/footer' +import { RawInteractiveMessage } from './raw-interactive' import { channels } from '.botpress' import * as bp from '.botpress' +// Standalone Interactive body cap (CTA URL & Reply Buttons share it) +const BODY_MAX_LENGTH = 1024 +const BUTTON_ID_MAX_LENGTH = 256 + +const ZERO_WIDTH_SPACE = '​' + type Card = channels.channel.card.Card +export const formatCardBodyText = (card: Card): string | undefined => { + const title = card.title?.trim() + const subtitle = card.subtitle?.trim() + if (!title && !subtitle) return undefined + const formatted = title && subtitle ? `**${title}**\n\n${subtitle}` : title ? `**${title}**` : subtitle! + return convertMarkdownToWhatsApp(formatted) +} + +const _truncatedBody = (text: string) => new Body(text.substring(0, BODY_MAX_LENGTH)) + type SDKAction = Card['actions'][number] type ActionURL = SDKAction & { action: 'url' } type ActionSay = SDKAction & { action: 'say' } @@ -23,49 +38,48 @@ export function* generateOutgoingMessages(card: Card, logger: bp.Logger) { const actions = card.actions if (actions.length === 0) { - // No actions, so we can't display an interactive message - for (const m of _generateHeader(card)) { + for (const m of _renderActionlessCard(card)) { yield m } return } - // We have to split the actions into two groups (URL actions and other actions) because buttons are sent differently than URLs - const urlActions = actions.filter(_isActionURL) - const nonUrlActions = actions.filter(_isNotActionUrl) - - if (urlActions.length === 0) { - // All actions are either postback or say - for (const m of _generateButtonInteractiveMessages(card, nonUrlActions, logger)) { - yield m + let isFirstMessage = true + for (const run of _partitionActionsByKind(actions)) { + const opts = { attachContextToFirst: isFirstMessage } + const messages = + run.kind === 'url' + ? _generateCTAUrlInteractiveMessages(card, run.actions, logger, opts) + : _generateButtonInteractiveMessages(card, run.actions, logger, opts) + for (const message of messages) { + yield message } - return + isFirstMessage = false } +} - if (nonUrlActions.length === 0) { - // All actions are URL - if (card.imageUrl) { - yield new Image(card.imageUrl) - } +type ActionRun = { kind: 'url'; actions: ActionURL[] } | { kind: 'button'; actions: Array } - for (const m of _generateCTAUrlInteractiveMessages(card, urlActions)) { - yield m +// WA doesn't allow mixing url (CTA) and postbacks (Reply), so we group sequential +function _partitionActionsByKind(actions: Action[]): ActionRun[] { + const runs: ActionRun[] = [] + for (const a of actions) { + if (a.action === 'url') { + const last = runs[runs.length - 1] + if (last && last.kind === 'url') last.actions.push(a) + else runs.push({ kind: 'url', actions: [a] }) + } else { + const last = runs[runs.length - 1] + if (last && last.kind === 'button') last.actions.push(a) + else runs.push({ kind: 'button', actions: [a] }) } - - return - } - - // We have have a mix of URL, postback and say actions - for (const m of _generateButtonInteractiveMessages(card, nonUrlActions, logger)) { - yield m - } - - for (const m of _generateCTAUrlInteractiveMessages(card, urlActions)) { - yield m } + return runs } -function* _generateHeader(card: Card) { +// No actions → no interactive bubble; emit the card's image+text as plain +// messages so the content still reaches the user. +function* _renderActionlessCard(card: Card) { if (card.imageUrl) { yield new Image(card.imageUrl, false, card.title) } else { @@ -77,43 +91,39 @@ function* _generateHeader(card: Card) { } } -function _isActionURL(action: Action): action is ActionURL { - return action.action === 'url' -} - -function _isNotActionUrl(action: Action): action is ActionSay | ActionPostback { - return !_isActionURL(action) -} - function* _generateButtonInteractiveMessages( card: Card, actions: Array, - logger: bp.Logger + logger: bp.Logger, + opts: { attachContextToFirst: boolean } ) { const [firstChunk, ...followingChunks] = chunkArray(actions, WHATSAPP.INTERACTIVE_MAX_BUTTONS_COUNT) if (firstChunk) { const actionButtons = _createActionButtons(firstChunk) if (actionButtons) { - yield new Interactive( - actionButtons, - body.create(card.title), - card.imageUrl ? new Header(new Image(card.imageUrl, false)) : undefined, - card.subtitle ? footer.create(card.subtitle) : undefined - ) + if (opts.attachContextToFirst) { + yield new Interactive( + actionButtons, + _truncatedBody(formatCardBodyText(card) ?? ZERO_WIDTH_SPACE), + card.imageUrl ? new Header(new Image(card.imageUrl, false)) : undefined + ) + } else { + // Earlier message in this card already carried the image/title/subtitle — + // render this run as a button-only bubble so context doesn't repeat. + yield new Interactive(actionButtons, _truncatedBody(ZERO_WIDTH_SPACE)) + } } else { logger.debug('No buttons in chunk, skipping first chunk') } } - if (followingChunks) { - for (const chunk of followingChunks) { - const actionsButtons = _createActionButtons(chunk) - if (!actionsButtons) { - logger.debug('No buttons in chunk, skipping') - continue - } - yield new Interactive(actionsButtons, body.create(card.title)) + for (const chunk of followingChunks || []) { + const actionsButtons = _createActionButtons(chunk) + if (!actionsButtons) { + logger.debug('No buttons in chunk, skipping') + continue } + yield new Interactive(actionsButtons, _truncatedBody(ZERO_WIDTH_SPACE)) } } @@ -133,25 +143,84 @@ function _createButtons(nonURLActions: Array) { return buttons } -function* _generateCTAUrlInteractiveMessages(card: Card, actions: ActionURL[]) { - let actionNumber = 1 +function* _generateCTAUrlInteractiveMessages( + card: Card, + actions: ActionURL[], + _logger: bp.Logger, + opts: { attachContextToFirst: boolean } +) { + let isFirst = true for (const action of actions) { - if (actionNumber === 1) { - // First CTA URL button will be in a WhatsApp card + const useFullContext = isFirst && opts.attachContextToFirst + if (useFullContext && card.imageUrl) { + yield buildCtaUrlMessage({ + imageUrl: card.imageUrl, + bodyText: formatCardBodyText(card) ?? action.value, + displayText: action.label, + url: action.value, + }) + } else if (useFullContext) { yield new Interactive( new ActionCTA(action.label, action.value), - body.create(card.subtitle ? convertMarkdownToWhatsApp(card.subtitle) : action.value), - card.title ? new Header(card.title) : undefined + _truncatedBody(formatCardBodyText(card) ?? action.value) ) } else { - // Subsequent CTA URL buttons will be standalone - yield new Interactive( - new ActionCTA(action.label, action.value), - body.create('\u200B') // Zero width space character used to force the interactive message to be sent (WhatsApp documentation says body is optional but it's not actually true) - ) + yield new Interactive(new ActionCTA(action.label, action.value), _truncatedBody(ZERO_WIDTH_SPACE)) } - actionNumber++ + isFirst = false } } + +export function buildCtaUrlPayload(opts: { imageUrl: string; bodyText?: string; displayText: string; url: string }) { + return { + type: 'cta_url' as const, + header: { type: 'image' as const, image: { link: opts.imageUrl } }, + ...(opts.bodyText !== undefined ? { body: { text: opts.bodyText } } : {}), + action: { + name: 'cta_url' as const, + parameters: { + display_text: opts.displayText.substring(0, WHATSAPP.BUTTON_LABEL_MAX_LENGTH), + url: opts.url, + }, + }, + } +} + +export function buildQuickReplyPayload(opts: { + imageUrl: string + bodyText?: string + buttons: ReadonlyArray<{ id: string; title: string }> +}) { + return { + type: 'cta_url' as const, + header: { type: 'image' as const, image: { link: opts.imageUrl } }, + ...(opts.bodyText !== undefined ? { body: { text: opts.bodyText } } : {}), + action: { + buttons: opts.buttons.map((b) => ({ + type: 'quick_reply' as const, + quick_reply: { + id: b.id.substring(0, BUTTON_ID_MAX_LENGTH), + title: b.title.substring(0, WHATSAPP.BUTTON_LABEL_MAX_LENGTH), + }, + })), + }, + } +} + +function buildCtaUrlMessage(opts: { + imageUrl: string + bodyText: string + displayText: string + url: string +}): RawInteractiveMessage { + return new RawInteractiveMessage( + buildCtaUrlPayload({ + imageUrl: opts.imageUrl, + bodyText: opts.bodyText.substring(0, BODY_MAX_LENGTH), + displayText: opts.displayText, + url: opts.url, + }) + ) +} diff --git a/integrations/whatsapp/src/channels/message-types/carousel.ts b/integrations/whatsapp/src/channels/message-types/carousel.ts index 60f742925dd..228b56ffa68 100644 --- a/integrations/whatsapp/src/channels/message-types/carousel.ts +++ b/integrations/whatsapp/src/channels/message-types/carousel.ts @@ -1,13 +1,186 @@ -import * as card from './card' +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] -export function* generateOutgoingMessages(carousel: Carousel, logger: bp.Logger) { - for (const i of carousel.items) { - for (const m of card.generateOutgoingMessages(i, logger)) { - yield m +// 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['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() || '' + 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 } + | { 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 }, + }) } diff --git a/integrations/whatsapp/src/channels/message-types/interactive/button.ts b/integrations/whatsapp/src/channels/message-types/interactive/button.ts index a9357dcdd6e..6a498a915af 100644 --- a/integrations/whatsapp/src/channels/message-types/interactive/button.ts +++ b/integrations/whatsapp/src/channels/message-types/interactive/button.ts @@ -4,5 +4,5 @@ const ID_MAX_LENGTH = 256 const TITLE_MAX_LENGTH = 20 export function create({ id, title }: { id: string; title: string }) { - return new Button(id.substring(0, ID_MAX_LENGTH), title.substring(0, TITLE_MAX_LENGTH)) + return new Button(id.trim().substring(0, ID_MAX_LENGTH), title.trim().substring(0, TITLE_MAX_LENGTH)) } diff --git a/integrations/whatsapp/src/channels/message-types/raw-interactive.ts b/integrations/whatsapp/src/channels/message-types/raw-interactive.ts new file mode 100644 index 00000000000..77e5ea4f817 --- /dev/null +++ b/integrations/whatsapp/src/channels/message-types/raw-interactive.ts @@ -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 + } +}