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

69 lines
2.1 KiB
Ruby

class Whatsapp::IdentifierSyncService
pattr_initialize [:contact_inbox!, :contact]
def perform(source_ids: [], username: nil, phone_number: nil)
create_contact_inboxes(source_ids)
update_contact(username, phone_number)
end
private
def create_contact_inboxes(source_ids)
source_ids.compact_blank.uniq.each do |source_id|
next if inbox.contact_inboxes.exists?(source_id: source_id)
inbox.contact_inboxes.create!(contact: synced_contact, source_id: source_id)
rescue ActiveRecord::RecordNotUnique
# A concurrent webhook (e.g. a status update bypassing the per-contact
# mutex) just inserted the same (inbox_id, source_id). Treat it as a
# no-op instead of falling through to ContactInboxBuilder's retry path,
# which would scramble the freshly-written row.
end
end
def update_contact(username, phone_number)
return if synced_contact.blank?
update_contact_phone_number(phone_number)
update_contact_username(username)
end
def update_contact_phone_number(phone_number)
phone_number = phone_number.presence
return if phone_number.blank? || synced_contact.phone_number.present?
return if synced_contact.account.contacts.where(phone_number: phone_number).where.not(id: synced_contact.id).exists?
synced_contact.update!(phone_number: phone_number)
end
def update_contact_username(username)
username = normalize_username(username)
return if username.blank?
synced_contact.update!(additional_attributes: additional_attributes_with_username(username))
end
def synced_contact
@synced_contact ||= contact || contact_inbox.contact
end
def inbox
@inbox ||= contact_inbox.inbox
end
def normalize_username(value)
value.to_s.sub(/\A@+/, '').presence
end
def additional_attributes_with_username(username)
attributes = synced_contact.additional_attributes.deep_dup
social_profiles = attributes['social_profiles'] || {}
social_profiles['whatsapp'] = username
attributes.merge(
'social_profiles' => social_profiles,
'social_whatsapp_user_name' => username
)
end
end