Files
chatwoot/app/services/twilio/incoming_message_service.rb
de137e8297 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>
2026-06-16 09:58:29 +04:00

222 lines
6.5 KiB
Ruby

class Twilio::IncomingMessageService
include ::FileTypeHelper
include ::Twilio::WhatsappIdentifierHelper
include ::Twilio::ReferralParamsHelper
pattr_initialize [:params!]
def perform
return if twilio_channel.blank?
set_contact
set_conversation
@message = @conversation.messages.build(
content: message_body,
account_id: @inbox.account_id,
inbox_id: @inbox.id,
message_type: :incoming,
sender: @contact,
source_id: params[:SmsSid],
content_attributes: message_content_attributes
)
attach_files
attach_location if location_message?
@message.save!
end
private
def twilio_channel
@twilio_channel ||= ::Channel::TwilioSms.find_by(messaging_service_sid: params[:MessagingServiceSid]) if params[:MessagingServiceSid].present?
if params[:AccountSid].present? && params[:To].present?
@twilio_channel ||= ::Channel::TwilioSms.find_by(account_sid: params[:AccountSid],
phone_number: params[:To])
end
log_channel_not_found if @twilio_channel.blank?
@twilio_channel
end
def log_channel_not_found
Rails.logger.warn(
'[TWILIO] Incoming message channel lookup failed ' \
"account_sid=#{params[:AccountSid]} " \
"to=#{params[:To]} " \
"messaging_service_sid=#{params[:MessagingServiceSid]} " \
"sms_sid=#{params[:SmsSid]}"
)
end
def inbox
@inbox ||= twilio_channel.inbox
end
def account
@account ||= inbox.account
end
# Twilio WhatsApp phone payloads arrive as `whatsapp:+E164`. BSUID-only
# payloads use `whatsapp:<BSUID>` in `From`, so this intentionally returns
# nil when `From` is not phone-shaped.
def phone_number
return params[:From] if twilio_channel.sms?
return unless twilio_whatsapp_phone_source?
params[:From].gsub('whatsapp:', '')
end
# Keep Twilio WhatsApp source ids in Twilio's native shape. Phone messages use
# `whatsapp:+E164`; BSUID-only messages fall back to `whatsapp:<BSUID>`.
def normalized_phone_number
return phone_number unless twilio_channel.whatsapp?
twilio_whatsapp_primary_source_id
end
def formatted_phone_number
return if phone_number.blank?
TelephoneNumber.parse(phone_number).international_number
end
def message_body
params[:Body]&.delete("\u0000")
end
def set_contact
source_id = twilio_channel.whatsapp? ? normalized_phone_number : params[:From]
@contact_inbox = twilio_contact_inbox(source_id)
@contact = @contact_inbox.contact
update_twilio_whatsapp_identifiers
# Update existing contact name if ProfileName is available and current name is just phone number
update_contact_name_if_needed
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: @contact.id,
contact_inbox_id: @contact_inbox.id,
additional_attributes: additional_attributes
}
end
def set_conversation
# if lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved
@conversation = if @inbox.lock_to_single_conversation
@contact_inbox.conversations.last
else
@contact_inbox.conversations.where
.not(status: :resolved).last
end
return if @conversation
@conversation = ::Conversation.create!(conversation_params)
end
def contact_attributes
{
name: contact_name,
phone_number: phone_number.presence,
additional_attributes: additional_attributes
}
end
def contact_name
params[:ProfileName].presence || formatted_phone_number || twilio_whatsapp_display_identifier || params[:From]
end
def additional_attributes
if twilio_channel.sms?
{
from_zip_code: params[:FromZip],
from_country: params[:FromCountry],
from_state: params[:FromState]
}
else
{}
end
end
def attach_files
num_media = params[:NumMedia].to_i
return if num_media.zero?
num_media.times do |i|
media_url = params[:"MediaUrl#{i}"]
attach_single_file(media_url) if media_url.present?
end
end
def attach_single_file(media_url)
attachment_file = download_attachment_file(media_url)
return if attachment_file.blank?
@message.attachments.new(
account_id: @message.account_id,
file_type: file_type(attachment_file.content_type),
file: {
io: attachment_file,
filename: attachment_file.original_filename,
content_type: attachment_file.content_type
}
)
end
def download_attachment_file(media_url)
download_with_auth(media_url)
rescue Down::Error, Down::ClientError => e
handle_download_attachment_error(e, media_url)
end
def download_with_auth(media_url)
auth_credentials = if twilio_channel.api_key_sid.present?
# When using api_key_sid, the auth token should be the api_secret_key
[twilio_channel.api_key_sid, twilio_channel.auth_token]
else
# When using account_sid, the auth token is the account's auth token
[twilio_channel.account_sid, twilio_channel.auth_token]
end
Down.download(media_url, http_basic_authentication: auth_credentials)
end
def handle_download_attachment_error(error, media_url)
Rails.logger.info "Error downloading attachment from Twilio: #{error.message}: Retrying without auth"
Down.download(media_url)
rescue StandardError => e
Rails.logger.info "Error downloading attachment from Twilio: #{e.message}: Skipping"
nil
end
def location_message?
params[:MessageType] == 'location' && params[:Latitude].present? && params[:Longitude].present?
end
def attach_location
@message.attachments.new(
account_id: @message.account_id,
file_type: :location,
coordinates_lat: params[:Latitude].to_f,
coordinates_long: params[:Longitude].to_f
)
end
def update_contact_name_if_needed
return if params[:ProfileName].blank?
return if @contact.name == params[:ProfileName]
# Only update if current name exactly matches the phone number or formatted phone number
return unless contact_name_matches_phone_number?
@contact.update!(name: params[:ProfileName])
end
def contact_name_matches_phone_number?
return false if phone_number.blank?
@contact.name == phone_number || @contact.name == formatted_phone_number
end
end