Files
chatwoot/app/services/twilio/whatsapp_identifier_helper.rb
40deaef458 feat: Store WhatsApp BSUID identifiers from inbound webhooks (#14436)
Adds storage support for WhatsApp business-scoped user identifiers
received from Meta Cloud API and Twilio WhatsApp webhooks. The change
keeps existing phone-based behavior intact, stores BSUID and parent
BSUID values as additional `contact_inboxes.source_id` rows for the same
contact, and allows BSUID-only inbound messages to create contacts,
conversations, and messages without requiring a phone number.

Related: https://github.com/chatwoot/chatwoot/issues/13837

**What changed**
- Extended WhatsApp source ID validation to accept regular BSUID and
parent BSUID formats.
- For Meta Cloud API, stores phone, `user_id`, and `parent_user_id`
identifiers as contact inbox source IDs when they are present.
- For Twilio WhatsApp, stores phone, `ExternalUserId`, and
`ParentExternalUserId` identifiers as contact inbox source IDs while
preserving the existing `whatsapp:` Twilio source ID shape.
- Supports BSUID-only inbound messages by creating a contact, contact
inbox, conversation, and message even when the phone number is missing.
- Links phone-first and later BSUID-only messages to the same contact
when the first payload contains both phone and BSUID.
- Stores WhatsApp usernames in contact `additional_attributes`, matching
existing social channel patterns.
- Keeps existing phone-based outbound and new-conversation behavior
unchanged for this milestone.

**How to test**
1. Send a Meta Cloud webhook payload with both `wa_id` and `user_id`.
2. Verify Chatwoot creates or finds the phone `contact_inbox` and also
creates a BSUID `contact_inbox` for the same contact.
3. Send a later Meta Cloud payload for the same user with only `user_id`
/ `from_user_id`.
4. Verify Chatwoot finds the BSUID `contact_inbox` and creates the
inbound message without requiring a phone number.
5. Send a Twilio WhatsApp webhook with `From: whatsapp:+E164`,
`ExternalUserId`, and optionally `ParentExternalUserId`.
6. Verify Chatwoot stores the Twilio phone and BSUID identifiers as
`whatsapp:`-prefixed source IDs for the same contact.
7. Send a Twilio WhatsApp webhook where `From` is `whatsapp:<BSUID>` and
there is no phone number.
8. Verify Chatwoot creates the contact, contact inbox, conversation, and
message without a phone number.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-05-20 13:36:43 +04:00

70 lines
2.1 KiB
Ruby

module Twilio::WhatsappIdentifierHelper
TWILIO_WHATSAPP_BSUID_SOURCE_ID_REGEX = Regexp.new("\\Awhatsapp:#{RegexHelper::WHATSAPP_BSUID_PATTERN}\\z")
def update_twilio_whatsapp_identifiers
return unless twilio_channel.whatsapp?
Whatsapp::IdentifierSyncService.new(contact_inbox: @contact_inbox, contact: @contact).perform(
source_ids: twilio_whatsapp_source_ids,
username: params[:ProfileUsername].presence || params[:Username],
phone_number: phone_number.presence
)
end
def twilio_whatsapp_phone_source?
params[:From].to_s.match?(/\Awhatsapp:\+\d{1,15}\z/)
end
def twilio_whatsapp_bsuid
params[:ExternalUserId].presence || twilio_whatsapp_bsuid_source_id
end
def twilio_whatsapp_display_identifier
twilio_whatsapp_bsuid.to_s.delete_prefix('whatsapp:').presence
end
def twilio_whatsapp_source_ids
[
twilio_whatsapp_phone_source_id,
twilio_whatsapp_source_id(params[:ExternalUserId].presence) || twilio_whatsapp_bsuid_source_id,
twilio_whatsapp_source_id(params[:ParentExternalUserId].presence)
].compact_blank.uniq
end
def twilio_whatsapp_primary_source_id
twilio_whatsapp_source_ids.first
end
def twilio_whatsapp_phone_source_id
return if phone_number.blank?
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider("whatsapp:#{phone_number}", :twilio)
end
def twilio_whatsapp_source_id(identifier)
identifier = identifier.to_s
return if identifier.blank?
"whatsapp:#{identifier.delete_prefix('whatsapp:')}"
end
def twilio_whatsapp_bsuid_source_id
from = params[:From].to_s
return from if from.match?(TWILIO_WHATSAPP_BSUID_SOURCE_ID_REGEX)
end
def twilio_contact_inbox(source_id)
ContactInboxSourceIdResolver.new(
inbox: inbox,
source_ids: twilio_contact_inbox_source_ids(source_id),
contact_attributes: contact_attributes
).perform
end
def twilio_contact_inbox_source_ids(source_id)
return [source_id] unless twilio_channel.whatsapp?
twilio_whatsapp_source_ids.presence || [source_id]
end
end