diff --git a/.gitignore b/.gitignore index 7fb7a1fbaa0..af9df9e35f9 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ __snapshots__ tilt_config.json /.idea hubspot.config.yml +AGENTS.md +CLAUDE.md diff --git a/integrations/whatsapp/integration.definition.ts b/integrations/whatsapp/integration.definition.ts index e77482182c1..5364d245d2d 100644 --- a/integrations/whatsapp/integration.definition.ts +++ b/integrations/whatsapp/integration.definition.ts @@ -93,7 +93,7 @@ const defaultBotPhoneNumberId = { } export const INTEGRATION_NAME = 'whatsapp' -export const INTEGRATION_VERSION = '4.5.20' +export const INTEGRATION_VERSION = '4.7.0' export default new IntegrationDefinition({ name: INTEGRATION_NAME, version: INTEGRATION_VERSION, @@ -266,6 +266,14 @@ export default new IntegrationDefinition({ 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', + }, }, }, conversation: { diff --git a/integrations/whatsapp/src/auth.ts b/integrations/whatsapp/src/auth.ts index 132494f649c..bc17acd7870 100644 --- a/integrations/whatsapp/src/auth.ts +++ b/integrations/whatsapp/src/auth.ts @@ -13,7 +13,7 @@ const getWabaIdsFromTokenResponseSchema = z granular_scopes: z.array( z.object({ scope: z.string(), - target_ids: z.array(z.string()), + target_ids: z.array(z.string()).optional(), }) ), }), diff --git a/integrations/whatsapp/src/misc/types.ts b/integrations/whatsapp/src/misc/types.ts index 58d620d2440..15555d07f1c 100644 --- a/integrations/whatsapp/src/misc/types.ts +++ b/integrations/whatsapp/src/misc/types.ts @@ -21,6 +21,13 @@ const WhatsAppBaseMessageSchema = z.object({ 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({ @@ -152,6 +159,29 @@ 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 + const WhatsAppMessageValueSchema = z.object({ messaging_product: z.literal('whatsapp'), metadata: z.object({ @@ -160,6 +190,7 @@ const WhatsAppMessageValueSchema = z.object({ }), contacts: z.array(WhatsAppContactSchema).optional(), messages: z.array(WhatsAppMessageSchema).optional(), + statuses: z.array(WhatsAppStatusSchema).optional(), }) export type WhatsAppMessageValue = z.infer diff --git a/integrations/whatsapp/src/webhook/handler.ts b/integrations/whatsapp/src/webhook/handler.ts index a48062d6a05..5b0428dbc75 100644 --- a/integrations/whatsapp/src/webhook/handler.ts +++ b/integrations/whatsapp/src/webhook/handler.ts @@ -6,6 +6,7 @@ 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' @@ -56,6 +57,9 @@ const _handler: bp.IntegrationProps['handler'] = async (props: bp.HandlerProps) switch (changes.field) { case 'messages': + for (const status of changes.value.statuses ?? []) { + await statusHandler(status, props) + } for (const message of changes.value.messages ?? []) { if (message.type === 'reaction') { await reactionHandler(message, props) diff --git a/integrations/whatsapp/src/webhook/handlers/messages.ts b/integrations/whatsapp/src/webhook/handlers/messages.ts index 42da8acd957..358a30901e4 100644 --- a/integrations/whatsapp/src/webhook/handlers/messages.ts +++ b/integrations/whatsapp/src/webhook/handlers/messages.ts @@ -89,7 +89,11 @@ async function _handleIncomingMessage( }: ValueOf & { incomingMessageType?: string; replyTo?: string }) => { logger.forBot().debug(`Received ${incomingMessageType ?? type} message from WhatsApp:`, payload) return client.getOrCreateMessage({ - tags: { id: message.id, replyTo }, + tags: { + id: message.id, + replyTo, + ..._processReferralTags(message, logger), + }, type, payload, userId: user.id, @@ -245,3 +249,34 @@ function _getMediaExpiry(ctx: bp.Context) { const expiresAt = new Date(Date.now() + expiryDelayHours * 60 * 60 * 1000) return expiresAt.toISOString() } + +function _processReferralTags(message: WhatsAppMessage, logger: bp.Logger): Record { + const { referral } = message + if (!referral) { + return {} + } + + const tags: Record = {} + + 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 +} diff --git a/integrations/whatsapp/src/webhook/handlers/status.ts b/integrations/whatsapp/src/webhook/handlers/status.ts new file mode 100644 index 00000000000..a91db6ec38e --- /dev/null +++ b/integrations/whatsapp/src/webhook/handlers/status.ts @@ -0,0 +1,22 @@ +import { WhatsAppStatusValue } from '../../misc/types' +import * as bp from '.botpress' + +export const statusHandler = async (value: WhatsAppStatusValue, props: bp.HandlerProps) => { + const { logger } = props + + if (value.status === '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}` + ) + } +}