feat: Capture CTWA referral metadata for WhatsApp conversations (#14681)
Adds support for capturing Click-to-WhatsApp ad referral metadata from incoming WhatsApp messages. This stores Meta Cloud API `referral` payloads on the incoming message `content_attributes` and normalizes Twilio `Referral*` callback fields into the same shape. The UI display is intentionally deferred until Instagram, Messenger, and TikTok referral payloads are captured as well, so we can design one cross-channel referral surface instead of a WhatsApp-only sidebar block. Why message-level storage Meta sends CTWA referral details as part of the inbound message payload, not as a stable conversation-level webhook. The referral represents the exact ad click that produced that specific customer message, and later messages in the same conversation may not carry the same context. Storing the normalized referral on the message preserves the original webhook semantics, avoids adding conversation-level attribution that could become stale or ambiguous, and keeps support for both Cloud API and Twilio payloads aligned behind `content_attributes.referral`. Fixes https://linear.app/chatwoot/issue/CW-7090/surface-meta-ctwa-referral-on-incoming-whatsapp-messages-cloud-api Closes https://github.com/chatwoot/chatwoot/issues/13995, https://github.com/chatwoot/chatwoot/issues/12560, https://github.com/chatwoot/chatwoot/issues/13006 Related community PRs - https://github.com/chatwoot/chatwoot/pull/13130 - https://github.com/chatwoot/chatwoot/pull/14180 - https://github.com/chatwoot/chatwoot/pull/14121 Related follow-ups - https://linear.app/chatwoot/issue/CW-7301/capture-instagram-ad-referral-metadata-on-incoming-messages - https://linear.app/chatwoot/issue/CW-7302/capture-messenger-ad-referral-metadata-on-facebook-page-conversations - https://linear.app/chatwoot/issue/CW-7303/capture-tiktok-ad-referral-metadata-from-im-referral-msg-events How to test 1. Send or replay a WhatsApp Cloud API inbound message that contains a `messages[0].referral` payload from a Click-to-WhatsApp ad. 2. Confirm the generated incoming message stores the payload under `content_attributes.referral`. 3. Repeat with a Twilio WhatsApp callback containing `Referral*` fields and confirm the stored message has the same normalized `content_attributes.referral` structure. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
This commit is contained in:
@@ -35,7 +35,17 @@ class Twilio::CallbackController < ApplicationController
|
||||
:ExternalUserId,
|
||||
:ParentExternalUserId,
|
||||
:ProfileUsername,
|
||||
:Username
|
||||
:Username,
|
||||
:ReferralBody,
|
||||
:ReferralHeadline,
|
||||
:ReferralSourceId,
|
||||
:ReferralSourceType,
|
||||
:ReferralSourceUrl,
|
||||
:ReferralMediaId,
|
||||
:ReferralMediaContentType,
|
||||
:ReferralMediaUrl,
|
||||
:ReferralNumMedia,
|
||||
:ReferralCtwaClid
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class Twilio::IncomingMessageService
|
||||
include ::FileTypeHelper
|
||||
include ::Twilio::WhatsappIdentifierHelper
|
||||
include ::Twilio::ReferralParamsHelper
|
||||
|
||||
pattr_initialize [:params!]
|
||||
|
||||
@@ -15,7 +16,8 @@ class Twilio::IncomingMessageService
|
||||
inbox_id: @inbox.id,
|
||||
message_type: :incoming,
|
||||
sender: @contact,
|
||||
source_id: params[:SmsSid]
|
||||
source_id: params[:SmsSid],
|
||||
content_attributes: message_content_attributes
|
||||
)
|
||||
attach_files
|
||||
attach_location if location_message?
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
module Twilio::ReferralParamsHelper
|
||||
REFERRAL_PARAM_MAPPING = {
|
||||
source_id: :ReferralSourceId,
|
||||
source_type: :ReferralSourceType,
|
||||
source_url: :ReferralSourceUrl,
|
||||
headline: :ReferralHeadline,
|
||||
body: :ReferralBody,
|
||||
media_id: :ReferralMediaId,
|
||||
media_content_type: :ReferralMediaContentType,
|
||||
media_url: :ReferralMediaUrl,
|
||||
num_media: :ReferralNumMedia,
|
||||
ctwa_clid: :ReferralCtwaClid
|
||||
}.freeze
|
||||
|
||||
def message_content_attributes
|
||||
referral_attributes.present? ? { referral: referral_attributes } : {}
|
||||
end
|
||||
|
||||
def referral_attributes
|
||||
return {} unless twilio_channel.whatsapp?
|
||||
return {} if params[:ReferralSourceId].blank?
|
||||
|
||||
REFERRAL_PARAM_MAPPING.each_with_object({}) do |(attribute, param_key), result|
|
||||
value = params[param_key]
|
||||
result[attribute] = value if value.present?
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -91,7 +91,7 @@ class Whatsapp::IncomingMessageBaseService
|
||||
def create_contact_messages(message)
|
||||
message['contacts'].each do |contact|
|
||||
# Pass source_id from parent message since contact objects don't have :id
|
||||
create_message(contact, source_id: message[:id])
|
||||
create_message(contact, source_id: message[:id], content_attributes_source: message)
|
||||
attach_contact(contact)
|
||||
@message.save!
|
||||
end
|
||||
@@ -158,10 +158,7 @@ class Whatsapp::IncomingMessageBaseService
|
||||
)
|
||||
end
|
||||
|
||||
def create_message(message, source_id: nil)
|
||||
content_attrs = outgoing_echo ? { external_echo: true } : {}
|
||||
content_attrs[:in_reply_to_external_id] = @in_reply_to_external_id if @in_reply_to_external_id.present?
|
||||
|
||||
def create_message(message, source_id: nil, content_attributes_source: message)
|
||||
@message = @conversation.messages.build(
|
||||
content: message_content(message),
|
||||
account_id: @inbox.account_id,
|
||||
@@ -171,10 +168,18 @@ class Whatsapp::IncomingMessageBaseService
|
||||
status: outgoing_echo ? :delivered : :sent,
|
||||
sender: outgoing_echo ? nil : @contact,
|
||||
source_id: (source_id || message[:id]).to_s,
|
||||
content_attributes: content_attrs
|
||||
content_attributes: message_content_attributes(content_attributes_source)
|
||||
)
|
||||
end
|
||||
|
||||
def message_content_attributes(message)
|
||||
content_attrs = outgoing_echo ? { external_echo: true } : {}
|
||||
content_attrs[:in_reply_to_external_id] = @in_reply_to_external_id if @in_reply_to_external_id.present?
|
||||
referral_content_attrs = referral_attributes(message)
|
||||
content_attrs[:referral] = referral_content_attrs if referral_content_attrs.present?
|
||||
content_attrs
|
||||
end
|
||||
|
||||
def attach_contact(contact)
|
||||
phones = contact[:phones]
|
||||
phones = [{ phone: 'Phone number is not available' }] if phones.blank?
|
||||
|
||||
@@ -71,6 +71,12 @@ module Whatsapp::IncomingMessageServiceHelpers
|
||||
@in_reply_to_external_id = message['context']&.[]('id')
|
||||
end
|
||||
|
||||
def referral_attributes(message)
|
||||
return {} if outgoing_echo
|
||||
|
||||
message[:referral]&.to_h&.deep_stringify_keys || {}
|
||||
end
|
||||
|
||||
def find_message_by_source_id(source_id)
|
||||
return unless source_id
|
||||
|
||||
|
||||
@@ -13,7 +13,15 @@ RSpec.describe 'Twilio::CallbacksController', type: :request do
|
||||
'SmsSid' => 'SM123',
|
||||
'ExternalUserId' => 'IN.2081978709342942',
|
||||
'ParentExternalUserId' => 'IN.ENT.9081726354',
|
||||
'ProfileUsername' => 'muhsin'
|
||||
'ProfileUsername' => 'muhsin',
|
||||
'ReferralCtwaClid' => 'AfjyUDlaIoiweZDnlzmDTEaG',
|
||||
'ReferralSourceId' => '120237244350960485',
|
||||
'ReferralSourceUrl' => 'https://fb.me/4tBfhWhjr',
|
||||
'ReferralSourceType' => 'ad',
|
||||
'ReferralHeadline' => 'German citizenship lawyer',
|
||||
'ReferralBody' => 'Fast-track your German citizenship',
|
||||
'ReferralMediaId' => '',
|
||||
'ReferralNumMedia' => '0'
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -511,6 +511,60 @@ describe Twilio::IncomingMessageService do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'When the incoming WhatsApp message has CTWA referral parameters' do
|
||||
let!(:whatsapp_twilio_channel) do
|
||||
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
|
||||
inbox: create(:inbox, account: account, greeting_enabled: false))
|
||||
end
|
||||
|
||||
it 'stores normalized referral attributes on the message' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: 'whatsapp:+491741763110',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
|
||||
Body: 'Hallo! Kann ich hierzu mehr Informationen erhalten?',
|
||||
ReferralCtwaClid: 'AfjyUDlaIoiweZDnlzmDTEaG',
|
||||
ReferralSourceId: '120237244350960485',
|
||||
ReferralSourceUrl: 'https://fb.me/4tBfhWhjr',
|
||||
ReferralSourceType: 'ad',
|
||||
ReferralHeadline: 'German citizenship lawyer',
|
||||
ReferralBody: 'Fast-track your German citizenship',
|
||||
ReferralMediaId: '',
|
||||
ReferralNumMedia: '0'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
|
||||
message = whatsapp_twilio_channel.inbox.messages.last
|
||||
expect(message.content_attributes['referral']).to eq(
|
||||
'ctwa_clid' => 'AfjyUDlaIoiweZDnlzmDTEaG',
|
||||
'source_id' => '120237244350960485',
|
||||
'source_url' => 'https://fb.me/4tBfhWhjr',
|
||||
'source_type' => 'ad',
|
||||
'headline' => 'German citizenship lawyer',
|
||||
'body' => 'Fast-track your German citizenship',
|
||||
'num_media' => '0'
|
||||
)
|
||||
end
|
||||
|
||||
it 'does not add referral attributes when ReferralSourceId is absent' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: 'whatsapp:+491741763110',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
|
||||
Body: 'Regular WhatsApp message',
|
||||
ReferralCtwaClid: 'AfjyUDlaIoiweZDnlzmDTEaG'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
|
||||
message = whatsapp_twilio_channel.inbox.messages.last
|
||||
expect(message.content_attributes).not_to have_key('referral')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'When the incoming number is a Brazilian number in new format with 9 included' do
|
||||
let!(:whatsapp_twilio_channel) do
|
||||
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
|
||||
|
||||
@@ -234,6 +234,88 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message contains referral data' do
|
||||
let(:referral_params) do
|
||||
{
|
||||
phone_number: whatsapp_channel.phone_number,
|
||||
object: 'whatsapp_business_account',
|
||||
entry: [{
|
||||
changes: [{
|
||||
value: {
|
||||
contacts: [{ profile: { name: 'Mom' }, wa_id: '255718573302', user_id: 'TZ.1040042605869930' }],
|
||||
messages: [{
|
||||
referral: {
|
||||
source_url: 'https://fb.me/3TYpooaRT',
|
||||
source_id: '52558118838064',
|
||||
source_type: 'ad',
|
||||
body: 'washa data tu',
|
||||
headline: 'Diana Digital',
|
||||
media_type: 'video',
|
||||
video_url: 'https://www.facebook.com/reel/1438165771395493/',
|
||||
thumbnail_url: 'https://scontent.xx.fbcdn.net/sample.jpg',
|
||||
ctwa_clid: 'AfhcQdP2E4A8wWpeb1FqUzUi',
|
||||
welcome_message: {
|
||||
text: 'Hi! Please let us know how we can help you.'
|
||||
}
|
||||
},
|
||||
from: '255718573302',
|
||||
from_user_id: 'TZ.1040042605869930',
|
||||
id: 'wamid.CTWA_REFERRAL_MESSAGE',
|
||||
timestamp: '1780649766',
|
||||
text: { body: 'Hello nielekeze' },
|
||||
type: 'text'
|
||||
}]
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
it 'stores the referral payload in message content attributes' do
|
||||
described_class.new(inbox: whatsapp_channel.inbox, params: referral_params).perform
|
||||
|
||||
message = whatsapp_channel.inbox.messages.last
|
||||
expect(message.content).to eq('Hello nielekeze')
|
||||
expect(message.content_attributes['referral']).to include(
|
||||
'source_url' => 'https://fb.me/3TYpooaRT',
|
||||
'source_id' => '52558118838064',
|
||||
'source_type' => 'ad',
|
||||
'body' => 'washa data tu',
|
||||
'headline' => 'Diana Digital',
|
||||
'media_type' => 'video',
|
||||
'video_url' => 'https://www.facebook.com/reel/1438165771395493/',
|
||||
'thumbnail_url' => 'https://scontent.xx.fbcdn.net/sample.jpg',
|
||||
'ctwa_clid' => 'AfhcQdP2E4A8wWpeb1FqUzUi',
|
||||
'welcome_message' => { 'text' => 'Hi! Please let us know how we can help you.' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'preserves the referral payload when the message contains contacts' do
|
||||
contacts_referral_params = referral_params.deep_dup
|
||||
parent_message = contacts_referral_params.dig(:entry, 0, :changes, 0, :value, :messages, 0)
|
||||
parent_message[:type] = 'contacts'
|
||||
parent_message.delete(:text)
|
||||
parent_message[:contacts] = [{
|
||||
name: {
|
||||
formatted_name: 'Diana Digital',
|
||||
first_name: 'Diana',
|
||||
last_name: 'Digital'
|
||||
},
|
||||
phones: [{ phone: '+255718573302' }]
|
||||
}]
|
||||
|
||||
described_class.new(inbox: whatsapp_channel.inbox, params: contacts_referral_params).perform
|
||||
|
||||
message = whatsapp_channel.inbox.messages.last
|
||||
expect(message.content).to eq('Diana Digital')
|
||||
expect(message.content_attributes['referral']).to include(
|
||||
'source_id' => '52558118838064',
|
||||
'headline' => 'Diana Digital',
|
||||
'ctwa_clid' => 'AfhcQdP2E4A8wWpeb1FqUzUi'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message is a reply (has context)' do
|
||||
let(:reply_params) do
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user