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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion integrations/whatsapp/integration.definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ const defaultBotPhoneNumberId = {
}

export const INTEGRATION_NAME = 'whatsapp'
export const INTEGRATION_VERSION = '4.15.0'
export const INTEGRATION_VERSION = '4.16.0'
export default new IntegrationDefinition({
name: INTEGRATION_NAME,
version: INTEGRATION_VERSION,
Expand Down Expand Up @@ -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.',
},
},
},
},
Expand All @@ -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: {
Expand Down
12 changes: 8 additions & 4 deletions integrations/whatsapp/src/channels/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
}

Expand All @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions integrations/whatsapp/src/misc/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
66 changes: 43 additions & 23 deletions integrations/whatsapp/src/webhook/handlers/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 10 additions & 2 deletions integrations/whatsapp/src/webhook/handlers/reaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions integrations/whatsapp/src/webhook/handlers/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand All @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion integrations/zendesk/integration.definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
10 changes: 9 additions & 1 deletion packages/cli/src/command-implementations/dev-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,15 @@ export class DevCommand extends ProjectCommand<DevCommandDefinition> {

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(
Expand Down
24 changes: 13 additions & 11 deletions packages/common/src/entity-helpers/create-or-update-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,23 @@ export const createOrUpdateUser = async <
): Promise<Awaited<ReturnType<Client['createUser']>>> => {
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<Record<UserTags, string | null>> = { ...matchingUsers[0]!.tags, ...props.tags }
return (
matchingUsers.length === 1
? await props.client.updateUser({
...matchingUsers[0]!,
...props,
tags: updateTags as Cast<typeof updateTags, Record<string, string | null>>,
})
: await props.client.createUser(props)
) as Awaited<ReturnType<Client['createUser']>>

if (firstUser) {
const updateTags: Partial<Record<UserTags, string | null>> = { ...firstUser.tags, ...props.tags }
return (await props.client.updateUser({
...firstUser,
...props,
tags: updateTags as Cast<typeof updateTags, Record<string, string | null>>,
})) as Awaited<ReturnType<Client['createUser']>>
}

return (await props.client.createUser(props)) as Awaited<ReturnType<Client['createUser']>>
}

const _getFilteredTags = <TAGS extends client.User['tags']>({
Expand Down
Loading