diff --git a/integrations/whatsapp/integration.definition.ts b/integrations/whatsapp/integration.definition.ts index 7f9cc3d9f6a..3a690a7a4ce 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.15.0' +export const INTEGRATION_VERSION = '4.16.0' export default new IntegrationDefinition({ name: INTEGRATION_NAME, version: INTEGRATION_VERSION, @@ -358,6 +358,10 @@ export default new IntegrationDefinition({ 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.', + }, }, }, }, @@ -376,6 +380,15 @@ export default new IntegrationDefinition({ 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: { diff --git a/integrations/whatsapp/src/channels/channel.ts b/integrations/whatsapp/src/channels/channel.ts index 7df116a36d3..400526a50ee 100644 --- a/integrations/whatsapp/src/channels/channel.ts +++ b/integrations/whatsapp/src/channels/channel.ts @@ -267,7 +267,9 @@ async function _send({ client, ctx, conversation, logger, message, ack }: SendMe const whatsapp = await getAuthenticatedWhatsappClient(client, ctx) const botPhoneNumberId = conversation.tags.botPhoneNumberId - const userPhoneNumber = conversation.tags.userPhone + // 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) { @@ -277,10 +279,12 @@ async function _send({ client, ctx, conversation, logger, message, ack }: SendMe return } - if (!userPhoneNumber) { + if (!recipient) { logger .forBot() - .error("Cannot send message to WhatsApp because the user's phone number isn't set in the conversation tags") + .error( + "Cannot send message to WhatsApp because neither the user's phone number nor user ID is set in the conversation tags" + ) return } @@ -290,7 +294,7 @@ async function _send({ client, ctx, conversation, logger, message, ack }: SendMe logger.forBot().info(`Retrying to send ${messageType} message to WhatsApp (attempt ${i + 1}/${MAX_ATTEMPT})...`) } - const result = await whatsapp.sendMessage(botPhoneNumberId, userPhoneNumber, message) + const result = await whatsapp.sendMessage(botPhoneNumberId, recipient, message) const repeat = 'error' in result && THROTTLING_CODES.has(result.error?.code ?? 0) return { repeat, diff --git a/integrations/whatsapp/src/misc/types.ts b/integrations/whatsapp/src/misc/types.ts index c21b1d4d667..093de76f366 100644 --- a/integrations/whatsapp/src/misc/types.ts +++ b/integrations/whatsapp/src/misc/types.ts @@ -2,16 +2,19 @@ import { z } from '@botpress/sdk' import { qualityScoreSchema } from 'definitions/events' const WhatsAppContactSchema = z.object({ - wa_id: z.string(), + 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(), + from: z.string().optional(), + from_user_id: z.string().optional(), id: z.string(), timestamp: z.string(), type: z.string(), diff --git a/integrations/whatsapp/src/webhook/handlers/messages.ts b/integrations/whatsapp/src/webhook/handlers/messages.ts index 5a471e53fec..5bb75e7e153 100644 --- a/integrations/whatsapp/src/webhook/handlers/messages.ts +++ b/integrations/whatsapp/src/webhook/handlers/messages.ts @@ -43,41 +43,61 @@ export const messagesHandler = async ( const phoneNumberId = value.metadata.phone_number_id await whatsapp.markAsRead(phoneNumberId, message.id) - 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, + 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}`) + { 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: formatPhoneNumberResponse.success ? formatPhoneNumberResponse.phoneNumber : message.from, + ...(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'], }) - const { contacts } = value - const contact = contacts?.[0] - if (!contact) { - logger.forBot().warn('No contacts found, ignoring message') - return - } + // 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: { - userId: contact.wa_id, + ...(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, diff --git a/integrations/whatsapp/src/webhook/handlers/reaction.ts b/integrations/whatsapp/src/webhook/handlers/reaction.ts index d12e16dd21b..369728fa426 100644 --- a/integrations/whatsapp/src/webhook/handlers/reaction.ts +++ b/integrations/whatsapp/src/webhook/handlers/reaction.ts @@ -22,13 +22,21 @@ export const reactionHandler = async (reactionMessage: WhatsAppReactionMessage, 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: reactionMessage.from, + userId: reactingUserId, newReactionTagValue: undefined, eventReaction: previousReaction, ...props, @@ -39,7 +47,7 @@ export const reactionHandler = async (reactionMessage: WhatsAppReactionMessage, await _handleReaction({ message, reactionEventType: 'reactionAdded', - userId: reactionMessage.from, + userId: reactingUserId, newReactionTagValue: currentReaction, eventReaction: currentReaction, ...props, diff --git a/integrations/whatsapp/src/webhook/handlers/sandbox.ts b/integrations/whatsapp/src/webhook/handlers/sandbox.ts index 1f633f4bd3b..80012087b68 100644 --- a/integrations/whatsapp/src/webhook/handlers/sandbox.ts +++ b/integrations/whatsapp/src/webhook/handlers/sandbox.ts @@ -14,6 +14,7 @@ export const isSandboxCommand = (props: bp.HandlerProps): boolean => { } 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 @@ -39,6 +40,9 @@ const _handleJoinCommand = async (props: bp.HandlerProps) => { } const userPhoneNumber = message.from + if (!userPhoneNumber) { + return NO_PHONE_ERROR + } const botPhoneNumberId = value.metadata.phone_number_id const whatsapp = await getAuthenticatedWhatsappClient(client, ctx) @@ -56,6 +60,9 @@ const _handleLeaveCommand = async (props: bp.HandlerProps) => { } const userPhoneNumber = message.from + if (!userPhoneNumber) { + return NO_PHONE_ERROR + } const botPhoneNumberId = value.metadata.phone_number_id const whatsapp = await getAuthenticatedWhatsappClient(client, ctx) diff --git a/integrations/zendesk/integration.definition.ts b/integrations/zendesk/integration.definition.ts index bda05f74d75..7457bab9940 100644 --- a/integrations/zendesk/integration.definition.ts +++ b/integrations/zendesk/integration.definition.ts @@ -6,7 +6,7 @@ import { actions, events, configuration, channels, states, user } from './src/de export default new sdk.IntegrationDefinition({ name: 'zendesk', title: 'Zendesk', - version: '3.1.3', + version: '3.1.4', icon: 'icon.svg', description: 'Optimize your support workflow. Trigger workflows from ticket updates as well as manage tickets, access conversations, and engage with customers.', diff --git a/packages/cli/package.json b/packages/cli/package.json index 8be24db08f3..69f3c9f005b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@botpress/cli", - "version": "6.8.3", + "version": "6.8.5", "description": "Botpress CLI", "scripts": { "build": "pnpm run build:types && pnpm run bundle && pnpm run template:gen", diff --git a/packages/cli/src/command-implementations/dev-command.ts b/packages/cli/src/command-implementations/dev-command.ts index 26b1eeb56c2..840db7f01cb 100644 --- a/packages/cli/src/command-implementations/dev-command.ts +++ b/packages/cli/src/command-implementations/dev-command.ts @@ -158,7 +158,15 @@ export class DevCommand extends ProjectCommand { await this._runBuild() worker = await this._spawnWorker(env, port) - await this._deploy(api, httpTunnelUrl) + + try { + await this._deploy(api, httpTunnelUrl) + } catch (thrown) { + if (worker.running) { + await worker.kill() + } + throw errors.BotpressCLIError.wrap(thrown, 'An error occurred while deploying the dev server') + } try { const watcher = await utils.filewatcher.FileWatcher.watch( diff --git a/packages/common/src/entity-helpers/create-or-update-user.ts b/packages/common/src/entity-helpers/create-or-update-user.ts index 1898c3ec777..b1f009649fa 100644 --- a/packages/common/src/entity-helpers/create-or-update-user.ts +++ b/packages/common/src/entity-helpers/create-or-update-user.ts @@ -24,21 +24,23 @@ export const createOrUpdateUser = async < ): Promise>> => { const { users: matchingUsers } = await props.client.listUsers({ tags: _getFilteredTags(props) }) - if (matchingUsers.length > 1) { + const [firstUser, ...otherUsers] = matchingUsers + if (otherUsers.length > 0) { throw new sdk.RuntimeError('Multiple users found with the same discriminating tags') } type UserTags = keyof TIntegration['user']['tags'] - const updateTags: Partial> = { ...matchingUsers[0]!.tags, ...props.tags } - return ( - matchingUsers.length === 1 - ? await props.client.updateUser({ - ...matchingUsers[0]!, - ...props, - tags: updateTags as Cast>, - }) - : await props.client.createUser(props) - ) as Awaited> + + if (firstUser) { + const updateTags: Partial> = { ...firstUser.tags, ...props.tags } + return (await props.client.updateUser({ + ...firstUser, + ...props, + tags: updateTags as Cast>, + })) as Awaited> + } + + return (await props.client.createUser(props)) as Awaited> } const _getFilteredTags = ({