Files
chatwoot/app/services/whatsapp/incoming_message_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

109 lines
4.0 KiB
Ruby

module Whatsapp::IncomingMessageIdentifierHelper
def set_contact_from_echo
message = messages_data.first
source_ids = outgoing_message_source_ids(message)
return if source_ids.blank?
contact_attributes = contact_attributes_for_identifier(source_ids.first, message[:to])
@contact_inbox = find_or_create_contact_inbox(
source_ids: source_ids,
contact_attributes: contact_attributes
)
@contact = @contact_inbox.contact
update_whatsapp_identifiers(source_ids: source_ids, phone_number: contact_attributes[:phone_number])
end
def set_contact_from_message
contact_params = @processed_params[:contacts]&.first
return if contact_params.blank?
source_ids = incoming_message_source_ids(contact_params)
return if source_ids.blank?
attrs = contact_attributes_from_contact_params(contact_params, source_ids.first)
@contact_inbox = find_or_create_contact_inbox(
source_ids: source_ids,
contact_attributes: attrs
)
@contact = @contact_inbox.contact
update_whatsapp_identifiers(source_ids: source_ids, username: contact_params.dig(:profile, :username), phone_number: attrs[:phone_number])
update_contact_with_profile_name(contact_params)
end
def find_or_create_contact_inbox(source_ids:, contact_attributes:)
ContactInboxSourceIdResolver.new(
inbox: inbox,
source_ids: source_ids,
contact_attributes: contact_attributes
).perform
end
def incoming_message_source_ids(contact_params)
[
whatsapp_phone_source_id(contact_params[:wa_id].presence || messages_data.first[:from].presence),
whatsapp_source_id(contact_params[:user_id].presence || messages_data.first[:from_user_id].presence),
whatsapp_source_id(contact_params[:parent_user_id].presence || messages_data.first[:from_parent_user_id].presence)
].compact_blank.uniq
end
def outgoing_message_source_ids(message)
[
whatsapp_phone_source_id(message[:to].presence),
whatsapp_source_id(message[:to_user_id].presence),
whatsapp_source_id(message[:to_parent_user_id].presence)
].compact_blank.uniq
end
def whatsapp_phone_source_id(identifier)
phone_number = whatsapp_phone_number(identifier)
return if phone_number.blank?
processed_waid(phone_number)
end
def whatsapp_source_id(identifier)
identifier.to_s.presence
end
def contact_attributes_from_contact_params(contact_params, source_identifier)
contact_attributes_for_identifier(
contact_params.dig(:profile, :name).presence || source_identifier,
contact_params[:wa_id].presence || messages_data.first[:from].presence
)
end
def contact_attributes_for_identifier(name, phone_identifier)
phone_number = whatsapp_phone_number(phone_identifier)
return { name: name } if phone_number.blank?
formatted_phone_number = "+#{phone_number}"
display_name = name == phone_identifier ? formatted_phone_number : name
{ name: display_name, phone_number: formatted_phone_number }
end
def update_whatsapp_identifiers(source_ids: [], username: nil, phone_number: nil)
Whatsapp::IdentifierSyncService.new(contact_inbox: @contact_inbox, contact: @contact).perform(source_ids: source_ids, username: username,
phone_number: phone_number)
end
def update_whatsapp_identifiers_from_status(status)
contact_inbox = @message&.conversation&.contact_inbox
return if contact_inbox.blank?
Whatsapp::IdentifierSyncService.new(contact_inbox: contact_inbox, contact: contact_inbox.contact).perform(
source_ids: status_source_ids(status)
)
end
def status_source_ids(status)
contact_params = @processed_params[:contacts]&.first || {}
[
whatsapp_source_id(status[:recipient_user_id]),
whatsapp_source_id(status[:recipient_parent_user_id]),
whatsapp_source_id(contact_params[:user_id]),
whatsapp_source_id(contact_params[:parent_user_id])
].compact_blank.uniq
end
end