From 40deaef458c4343bdc109caef0dbdc85a156a854 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 20 May 2026 13:36:43 +0400 Subject: [PATCH] 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:` 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> --- app/controllers/twilio/callback_controller.rb | 6 +- app/jobs/webhooks/whatsapp_events_job.rb | 31 ++++- .../contact_inbox_source_id_resolver.rb | 30 +++++ .../twilio/incoming_message_service.rb | 33 +++-- .../twilio/whatsapp_identifier_helper.rb | 69 ++++++++++ .../whatsapp/identifier_sync_service.rb | 68 ++++++++++ .../whatsapp/incoming_message_base_service.rb | 46 ++----- .../incoming_message_identifier_helper.rb | 108 ++++++++++++++++ .../incoming_message_service_helpers.rb | 8 ++ lib/regex_helper.rb | 8 +- .../twilio/callbacks_controller_spec.rb | 5 +- .../jobs/webhooks/whatsapp_events_job_spec.rb | 120 +++++++++++++++++ spec/models/contact_inbox_spec.rb | 37 +++++- .../twilio/incoming_message_service_spec.rb | 108 ++++++++++++++++ .../whatsapp/incoming_message_service_spec.rb | 122 ++++++++++++++++++ ...ing_message_whatsapp_cloud_service_spec.rb | 92 +++++++++++++ 16 files changed, 828 insertions(+), 63 deletions(-) create mode 100644 app/services/contact_inbox_source_id_resolver.rb create mode 100644 app/services/twilio/whatsapp_identifier_helper.rb create mode 100644 app/services/whatsapp/identifier_sync_service.rb create mode 100644 app/services/whatsapp/incoming_message_identifier_helper.rb diff --git a/app/controllers/twilio/callback_controller.rb b/app/controllers/twilio/callback_controller.rb index d607ba15101..53075a55562 100644 --- a/app/controllers/twilio/callback_controller.rb +++ b/app/controllers/twilio/callback_controller.rb @@ -31,7 +31,11 @@ class Twilio::CallbackController < ApplicationController :Latitude, :Longitude, :MessageType, - :ProfileName + :ProfileName, + :ExternalUserId, + :ParentExternalUserId, + :ProfileUsername, + :Username ) end end diff --git a/app/jobs/webhooks/whatsapp_events_job.rb b/app/jobs/webhooks/whatsapp_events_job.rb index 49b7265b8fe..f904b372320 100644 --- a/app/jobs/webhooks/whatsapp_events_job.rb +++ b/app/jobs/webhooks/whatsapp_events_job.rb @@ -91,10 +91,37 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob # Returns nil for status-only webhooks so they bypass the lock. def contact_sender_id(params) value = params.dig(:entry, 0, :changes, 0, :value) || params - message = (value[:messages] || value[:message_echoes])&.first + return contact_sender_id_from_message_echoes(value[:message_echoes]) if value[:message_echoes].present? + + contact_sender_id_from_messages(value[:messages], value[:contacts]) + end + + # Echo payloads are outbound messages from the WhatsApp Business app, so `to` + # points to the contact. Prefer parent BSUID when present so payloads that have + # both regular+parent BSUIDs serialize with parent-BSUID-only payloads. + def contact_sender_id_from_message_echoes(message_echoes) + message = message_echoes&.first return if message.blank? - message[:to] || message[:from] + [message[:to_parent_user_id], message[:to_user_id], message[:to]].compact_blank.first + end + + # Regular inbound payloads are sent by the contact, so `from` points to the + # contact. Prefer parent BSUID when present so payloads that have both + # regular+parent BSUIDs serialize with parent-BSUID-only payloads. + def contact_sender_id_from_messages(messages, contacts) + message = messages&.first + return if message.blank? + + contact = contacts&.first || {} + + [ + message[:from_parent_user_id], + contact[:parent_user_id], + message[:from_user_id], + contact[:user_id], + message[:from] + ].compact_blank.first end def channel_is_inactive?(channel) diff --git a/app/services/contact_inbox_source_id_resolver.rb b/app/services/contact_inbox_source_id_resolver.rb new file mode 100644 index 00000000000..2fcea90db88 --- /dev/null +++ b/app/services/contact_inbox_source_id_resolver.rb @@ -0,0 +1,30 @@ +class ContactInboxSourceIdResolver + pattr_initialize [:inbox!, :source_ids!, :contact_attributes!] + + def perform + existing_contact_inbox || create_contact_inbox + end + + private + + def existing_contact_inbox + normalized_source_ids.each do |source_id| + contact_inbox = inbox.contact_inboxes.find_by(source_id: source_id) + return contact_inbox if contact_inbox + end + + nil + end + + def create_contact_inbox + ::ContactInboxWithContactBuilder.new( + source_id: normalized_source_ids.first, + inbox: inbox, + contact_attributes: contact_attributes + ).perform + end + + def normalized_source_ids + @normalized_source_ids ||= source_ids.compact_blank.uniq + end +end diff --git a/app/services/twilio/incoming_message_service.rb b/app/services/twilio/incoming_message_service.rb index d67b6d5154d..dab55e521f3 100644 --- a/app/services/twilio/incoming_message_service.rb +++ b/app/services/twilio/incoming_message_service.rb @@ -1,5 +1,6 @@ class Twilio::IncomingMessageService include ::FileTypeHelper + include ::Twilio::WhatsappIdentifierHelper pattr_initialize [:params!] @@ -51,17 +52,27 @@ class Twilio::IncomingMessageService @account ||= inbox.account end + # Twilio WhatsApp phone payloads arrive as `whatsapp:+E164`. BSUID-only + # payloads use `whatsapp:` in `From`, so this intentionally returns + # nil when `From` is not phone-shaped. def phone_number - twilio_channel.sms? ? params[:From] : params[:From].gsub('whatsapp:', '') + 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:`. def normalized_phone_number return phone_number unless twilio_channel.whatsapp? - Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider("whatsapp:#{phone_number}", :twilio) + twilio_whatsapp_primary_source_id end def formatted_phone_number + return if phone_number.blank? + TelephoneNumber.parse(phone_number).international_number end @@ -71,15 +82,9 @@ class Twilio::IncomingMessageService def set_contact source_id = twilio_channel.whatsapp? ? normalized_phone_number : params[:From] - - contact_inbox = ::ContactInboxWithContactBuilder.new( - source_id: source_id, - inbox: inbox, - contact_attributes: contact_attributes - ).perform - - @contact_inbox = contact_inbox - @contact = contact_inbox.contact + @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 @@ -111,13 +116,13 @@ class Twilio::IncomingMessageService def contact_attributes { name: contact_name, - phone_number: phone_number, + phone_number: phone_number.presence, additional_attributes: additional_attributes } end def contact_name - params[:ProfileName].presence || formatted_phone_number + params[:ProfileName].presence || formatted_phone_number || twilio_whatsapp_display_identifier || params[:From] end def additional_attributes @@ -207,6 +212,8 @@ class Twilio::IncomingMessageService end def contact_name_matches_phone_number? + return false if phone_number.blank? + @contact.name == phone_number || @contact.name == formatted_phone_number end end diff --git a/app/services/twilio/whatsapp_identifier_helper.rb b/app/services/twilio/whatsapp_identifier_helper.rb new file mode 100644 index 00000000000..7318239036f --- /dev/null +++ b/app/services/twilio/whatsapp_identifier_helper.rb @@ -0,0 +1,69 @@ +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 diff --git a/app/services/whatsapp/identifier_sync_service.rb b/app/services/whatsapp/identifier_sync_service.rb new file mode 100644 index 00000000000..f0701a3fc8b --- /dev/null +++ b/app/services/whatsapp/identifier_sync_service.rb @@ -0,0 +1,68 @@ +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 diff --git a/app/services/whatsapp/incoming_message_base_service.rb b/app/services/whatsapp/incoming_message_base_service.rb index 5449c4740e9..82aa7ab18ba 100644 --- a/app/services/whatsapp/incoming_message_base_service.rb +++ b/app/services/whatsapp/incoming_message_base_service.rb @@ -3,6 +3,7 @@ # https://developers.facebook.com/docs/whatsapp/api/media/ class Whatsapp::IncomingMessageBaseService include ::Whatsapp::IncomingMessageServiceHelpers + include ::Whatsapp::IncomingMessageIdentifierHelper pattr_initialize [:inbox!, :params!, :outgoing_echo] @@ -46,9 +47,11 @@ class Whatsapp::IncomingMessageBaseService end def process_statuses - return unless find_message_by_source_id(@processed_params[:statuses].first[:id]) + status = @processed_params[:statuses].first + return unless find_message_by_source_id(status[:id]) - update_message_with_status(@message, @processed_params[:statuses].first) + update_whatsapp_identifiers_from_status(status) + update_message_with_status(@message, status) rescue ArgumentError => e Rails.logger.error "Error while processing whatsapp status update #{e.message}" end @@ -95,40 +98,6 @@ class Whatsapp::IncomingMessageBaseService end end - def set_contact_from_echo - # For echo messages, contact phone is in the 'to' field - phone_number = messages_data.first[:to] - waid = processed_waid(phone_number) - - contact_inbox = ::ContactInboxWithContactBuilder.new( - source_id: waid, - inbox: inbox, - contact_attributes: { name: "+#{phone_number}", phone_number: "+#{phone_number}" } - ).perform - - @contact_inbox = contact_inbox - @contact = contact_inbox.contact - end - - def set_contact_from_message - contact_params = @processed_params[:contacts]&.first - return if contact_params.blank? - - waid = processed_waid(contact_params[:wa_id]) - - contact_inbox = ::ContactInboxWithContactBuilder.new( - source_id: waid, - inbox: inbox, - contact_attributes: { name: contact_params.dig(:profile, :name), phone_number: "+#{messages_data.first[:from]}" } - ).perform - - @contact_inbox = contact_inbox - @contact = contact_inbox.contact - - # Update existing contact name if ProfileName is available and current name is just phone number - update_contact_with_profile_name(contact_params) - 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 @@ -224,7 +193,10 @@ class Whatsapp::IncomingMessageBaseService end def contact_name_matches_phone_number? - phone_number = "+#{messages_data.first[:from]}" + message_phone_number = whatsapp_phone_number(messages_data.first[:from]) + return false if message_phone_number.blank? + + phone_number = "+#{message_phone_number}" formatted_phone_number = TelephoneNumber.parse(phone_number).international_number @contact.name == phone_number || @contact.name == formatted_phone_number end diff --git a/app/services/whatsapp/incoming_message_identifier_helper.rb b/app/services/whatsapp/incoming_message_identifier_helper.rb new file mode 100644 index 00000000000..449b894d748 --- /dev/null +++ b/app/services/whatsapp/incoming_message_identifier_helper.rb @@ -0,0 +1,108 @@ +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 diff --git a/app/services/whatsapp/incoming_message_service_helpers.rb b/app/services/whatsapp/incoming_message_service_helpers.rb index bac6f62227a..8ec8842689b 100644 --- a/app/services/whatsapp/incoming_message_service_helpers.rb +++ b/app/services/whatsapp/incoming_message_service_helpers.rb @@ -51,6 +51,14 @@ module Whatsapp::IncomingMessageServiceHelpers Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider(waid, :cloud) end + def whatsapp_phone_number(identifier) + identifier = identifier.to_s + return if identifier.blank? + return unless identifier.match?(/\A\d{1,15}\z/) + + identifier + end + def error_webhook_event?(message) message.key?('errors') end diff --git a/lib/regex_helper.rb b/lib/regex_helper.rb index 2eeb895ea87..e9304f581e5 100644 --- a/lib/regex_helper.rb +++ b/lib/regex_helper.rb @@ -13,7 +13,9 @@ module RegexHelper # while notifications use CommonMarker for better markdown processing MENTION_REGEX = Regexp.new('\[(@[^\\]]+)\]\(mention://(?:user|team)/\d+/([^)]+)\)') - TWILIO_CHANNEL_SMS_REGEX = Regexp.new('^\+\d{1,15}\z') - TWILIO_CHANNEL_WHATSAPP_REGEX = Regexp.new('^whatsapp:\+\d{1,15}\z') - WHATSAPP_CHANNEL_REGEX = Regexp.new('^\d{1,15}\z') + TWILIO_CHANNEL_SMS_REGEX = Regexp.new('\A\+\d{1,15}\z') + WHATSAPP_BSUID_PATTERN = '[A-Z]{2}\.(?:ENT\.)?[A-Za-z0-9]{1,128}'.freeze + WHATSAPP_BSUID_REGEX = Regexp.new("\\A#{WHATSAPP_BSUID_PATTERN}\\z") + TWILIO_CHANNEL_WHATSAPP_REGEX = Regexp.new("\\A(?:whatsapp:\\+\\d{1,15}|whatsapp:#{WHATSAPP_BSUID_PATTERN})\\z") + WHATSAPP_CHANNEL_REGEX = Regexp.new("\\A(?:\\d{1,15}|#{WHATSAPP_BSUID_PATTERN})\\z") end diff --git a/spec/controllers/twilio/callbacks_controller_spec.rb b/spec/controllers/twilio/callbacks_controller_spec.rb index d16acf22988..1dc2991ae38 100644 --- a/spec/controllers/twilio/callbacks_controller_spec.rb +++ b/spec/controllers/twilio/callbacks_controller_spec.rb @@ -10,7 +10,10 @@ RSpec.describe 'Twilio::CallbacksController', type: :request do 'To' => '+0987654321', 'Body' => 'Test message', 'AccountSid' => 'AC123', - 'SmsSid' => 'SM123' + 'SmsSid' => 'SM123', + 'ExternalUserId' => 'IN.2081978709342942', + 'ParentExternalUserId' => 'IN.ENT.9081726354', + 'ProfileUsername' => 'muhsin' } end diff --git a/spec/jobs/webhooks/whatsapp_events_job_spec.rb b/spec/jobs/webhooks/whatsapp_events_job_spec.rb index ba8a19413bf..d8265810246 100644 --- a/spec/jobs/webhooks/whatsapp_events_job_spec.rb +++ b/spec/jobs/webhooks/whatsapp_events_job_spec.rb @@ -97,6 +97,126 @@ RSpec.describe Webhooks::WhatsappEventsJob do expect(Rails.logger).to receive(:warn).with("Inactive WhatsApp channel: unknown - #{unknown_phone}") job.perform_now(phone_number: unknown_phone) end + + it 'uses from_user_id as the mutex sender for BSUID-only inbound messages' do + bsuid = 'IN.2081978709342942' + wb_params = params.deep_dup + wb_params[:entry].first[:changes].first[:value][:messages] = [ + { from: '', from_user_id: bsuid, id: 'wamid-test', text: { body: 'Hello' }, type: 'text' } + ] + job_instance = described_class.new + mutex_key = format(Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, inbox_id: channel.inbox.id, sender_id: bsuid) + + allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service) + expect(job_instance).to receive(:with_lock).with(mutex_key, 30.seconds).and_yield + + job_instance.perform(wb_params) + end + + it 'prefers from_user_id as the mutex sender for mixed phone and BSUID inbound messages' do + bsuid = 'IN.2081978709342942' + wb_params = params.deep_dup + wb_params[:entry].first[:changes].first[:value][:messages] = [ + { from: '919745786257', from_user_id: bsuid, id: 'wamid-test', text: { body: 'Hello' }, type: 'text' } + ] + job_instance = described_class.new + mutex_key = format(Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, inbox_id: channel.inbox.id, sender_id: bsuid) + + allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service) + expect(job_instance).to receive(:with_lock).with(mutex_key, 30.seconds).and_yield + + job_instance.perform(wb_params) + end + + it 'uses contact user_id as the mutex sender when message from_user_id is missing' do + bsuid = 'IN.2081978709342942' + wb_params = params.deep_dup + wb_params[:entry].first[:changes].first[:value][:contacts] = [ + { profile: { name: 'Muhsin' }, wa_id: '919745786257', user_id: bsuid } + ] + wb_params[:entry].first[:changes].first[:value][:messages] = [ + { from: '919745786257', id: 'wamid-test', text: { body: 'Hello' }, type: 'text' } + ] + job_instance = described_class.new + mutex_key = format(Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, inbox_id: channel.inbox.id, sender_id: bsuid) + + allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service) + expect(job_instance).to receive(:with_lock).with(mutex_key, 30.seconds).and_yield + + job_instance.perform(wb_params) + end + + it 'prefers parent BSUID as the mutex sender for inbound messages with both identifiers' do + bsuid = 'IN.2081978709342942' + parent_bsuid = 'IN.ENT.9081726354' + wb_params = params.deep_dup + wb_params[:entry].first[:changes].first[:value][:contacts] = [ + { profile: { name: 'Muhsin' }, user_id: bsuid, parent_user_id: parent_bsuid } + ] + wb_params[:entry].first[:changes].first[:value][:messages] = [ + { from_user_id: bsuid, from_parent_user_id: parent_bsuid, id: 'wamid-test', text: { body: 'Hello' }, type: 'text' } + ] + job_instance = described_class.new + mutex_key = format(Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, inbox_id: channel.inbox.id, sender_id: parent_bsuid) + + allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service) + expect(job_instance).to receive(:with_lock).with(mutex_key, 30.seconds).and_yield + + job_instance.perform(wb_params) + end + + it 'uses to_user_id as the mutex sender for BSUID-only echo messages' do + bsuid = 'IN.2081978709342942' + wb_params = params.deep_dup + wb_params[:entry].first[:changes].first[:field] = 'smb_message_echoes' + wb_params[:entry].first[:changes].first[:value][:message_echoes] = [ + { from: channel.phone_number.delete('+'), to: '', to_user_id: bsuid, id: 'wamid-test', text: { body: 'Hello' }, type: 'text' } + ] + job_instance = described_class.new + mutex_key = format(Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, inbox_id: channel.inbox.id, sender_id: bsuid) + + allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service) + expect(job_instance).to receive(:with_lock).with(mutex_key, 30.seconds).and_yield + + job_instance.perform(wb_params) + end + + it 'prefers parent BSUID as the mutex sender for echo messages with both identifiers' do + bsuid = 'IN.2081978709342942' + parent_bsuid = 'IN.ENT.9081726354' + wb_params = params.deep_dup + wb_params[:entry].first[:changes].first[:field] = 'smb_message_echoes' + wb_params[:entry].first[:changes].first[:value][:message_echoes] = [ + { + from: channel.phone_number.delete('+'), to: '919745786257', to_user_id: bsuid, to_parent_user_id: parent_bsuid, + id: 'wamid-test', text: { body: 'Hello' }, type: 'text' + } + ] + job_instance = described_class.new + mutex_key = format(Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, inbox_id: channel.inbox.id, sender_id: parent_bsuid) + + allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service) + expect(job_instance).to receive(:with_lock).with(mutex_key, 30.seconds).and_yield + + job_instance.perform(wb_params) + end + + it 'prefers to_user_id as the mutex sender for mixed phone and BSUID echo messages' do + bsuid = 'IN.2081978709342942' + wb_params = params.deep_dup + wb_params[:entry].first[:changes].first[:field] = 'smb_message_echoes' + wb_params[:entry].first[:changes].first[:value][:message_echoes] = [ + { from: channel.phone_number.delete('+'), to: '919745786257', to_user_id: bsuid, id: 'wamid-test', text: { body: 'Hello' }, + type: 'text' } + ] + job_instance = described_class.new + mutex_key = format(Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, inbox_id: channel.inbox.id, sender_id: bsuid) + + allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service) + expect(job_instance).to receive(:with_lock).with(mutex_key, 30.seconds).and_yield + + job_instance.perform(wb_params) + end end context 'when default provider' do diff --git a/spec/models/contact_inbox_spec.rb b/spec/models/contact_inbox_spec.rb index c0a2faf9e7a..c58615e9e8a 100644 --- a/spec/models/contact_inbox_spec.rb +++ b/spec/models/contact_inbox_spec.rb @@ -56,16 +56,20 @@ RSpec.describe ContactInbox do whatsapp_inbox = create(:channel_whatsapp, sync_templates: false, validate_provider_config: false).inbox contact = create(:contact) valid_source_id = build(:contact_inbox, contact: contact, inbox: whatsapp_inbox, source_id: '1234567890') + valid_bsuid_source_id = build(:contact_inbox, contact: contact, inbox: whatsapp_inbox, source_id: 'IN.2081978709342942') + valid_parent_bsuid_source_id = build(:contact_inbox, contact: contact, inbox: whatsapp_inbox, source_id: 'IN.ENT.9081726354') ci_character_in_source_id = build(:contact_inbox, contact: contact, inbox: whatsapp_inbox, source_id: '1234567890aaa') ci_plus_in_source_id = build(:contact_inbox, contact: contact, inbox: whatsapp_inbox, source_id: '+1234567890') expect(valid_source_id.valid?).to be(true) + expect(valid_bsuid_source_id.valid?).to be(true) + expect(valid_parent_bsuid_source_id.valid?).to be(true) expect(ci_character_in_source_id.valid?).to be(false) expect(ci_character_in_source_id.errors.full_messages).to eq( - ['Source invalid source id for whatsapp inbox. valid Regex (?-mix:^\\d{1,15}\\z)'] + ["Source invalid source id for whatsapp inbox. valid Regex #{RegexHelper::WHATSAPP_CHANNEL_REGEX}"] ) expect(ci_plus_in_source_id.valid?).to be(false) expect(ci_plus_in_source_id.errors.full_messages).to eq( - ['Source invalid source id for whatsapp inbox. valid Regex (?-mix:^\\d{1,15}\\z)'] + ["Source invalid source id for whatsapp inbox. valid Regex #{RegexHelper::WHATSAPP_CHANNEL_REGEX}"] ) end @@ -78,11 +82,11 @@ RSpec.describe ContactInbox do expect(valid_source_id.valid?).to be(true) expect(ci_character_in_source_id.valid?).to be(false) expect(ci_character_in_source_id.errors.full_messages).to eq( - ['Source invalid source id for twilio sms inbox. valid Regex (?-mix:^\\+\\d{1,15}\\z)'] + ["Source invalid source id for twilio sms inbox. valid Regex #{RegexHelper::TWILIO_CHANNEL_SMS_REGEX}"] ) expect(ci_without_plus_in_source_id.valid?).to be(false) expect(ci_without_plus_in_source_id.errors.full_messages).to eq( - ['Source invalid source id for twilio sms inbox. valid Regex (?-mix:^\\+\\d{1,15}\\z)'] + ["Source invalid source id for twilio sms inbox. valid Regex #{RegexHelper::TWILIO_CHANNEL_SMS_REGEX}"] ) end @@ -90,18 +94,39 @@ RSpec.describe ContactInbox do twilio_whatsapp_inbox = create(:channel_twilio_sms, medium: :whatsapp).inbox contact = create(:contact) valid_source_id = build(:contact_inbox, contact: contact, inbox: twilio_whatsapp_inbox, source_id: 'whatsapp:+1234567890') + valid_bsuid_source_id = build(:contact_inbox, contact: contact, inbox: twilio_whatsapp_inbox, source_id: 'whatsapp:IN.2081978709342942') + valid_parent_bsuid_source_id = build(:contact_inbox, contact: contact, inbox: twilio_whatsapp_inbox, source_id: 'whatsapp:IN.ENT.9081726354') ci_character_in_source_id = build(:contact_inbox, contact: contact, inbox: twilio_whatsapp_inbox, source_id: 'whatsapp:+1234567890aaa') ci_without_plus_in_source_id = build(:contact_inbox, contact: contact, inbox: twilio_whatsapp_inbox, source_id: 'whatsapp:1234567890') expect(valid_source_id.valid?).to be(true) + expect(valid_bsuid_source_id.valid?).to be(true) + expect(valid_parent_bsuid_source_id.valid?).to be(true) expect(ci_character_in_source_id.valid?).to be(false) expect(ci_character_in_source_id.errors.full_messages).to eq( - ['Source invalid source id for twilio whatsapp inbox. valid Regex (?-mix:^whatsapp:\\+\\d{1,15}\\z)'] + ["Source invalid source id for twilio whatsapp inbox. valid Regex #{RegexHelper::TWILIO_CHANNEL_WHATSAPP_REGEX}"] ) expect(ci_without_plus_in_source_id.valid?).to be(false) expect(ci_without_plus_in_source_id.errors.full_messages).to eq( - ['Source invalid source id for twilio whatsapp inbox. valid Regex (?-mix:^whatsapp:\\+\\d{1,15}\\z)'] + ["Source invalid source id for twilio whatsapp inbox. valid Regex #{RegexHelper::TWILIO_CHANNEL_WHATSAPP_REGEX}"] ) end + + it 'rejects whatsapp BSUID source_id values longer than 128 alphanumeric characters' do + whatsapp_inbox = create(:channel_whatsapp, sync_templates: false, validate_provider_config: false).inbox + contact = create(:contact) + contact_inbox = build(:contact_inbox, contact: contact, inbox: whatsapp_inbox, source_id: "IN.#{'1' * 129}") + + expect(contact_inbox.valid?).to be(false) + end + + it 'rejects twilio whatsapp parent BSUID source_id values longer than 128 alphanumeric characters' do + twilio_whatsapp_inbox = create(:channel_twilio_sms, medium: :whatsapp).inbox + contact = create(:contact) + contact_inbox = build(:contact_inbox, contact: contact, inbox: twilio_whatsapp_inbox, + source_id: "whatsapp:IN.ENT.#{'1' * 129}") + + expect(contact_inbox.valid?).to be(false) + end end end end diff --git a/spec/services/twilio/incoming_message_service_spec.rb b/spec/services/twilio/incoming_message_service_spec.rb index 190a6c45a39..d50a0113d03 100644 --- a/spec/services/twilio/incoming_message_service_spec.rb +++ b/spec/services/twilio/incoming_message_service_spec.rb @@ -403,6 +403,114 @@ describe Twilio::IncomingMessageService do expect(existing_contact.name).to eq('Alice Johnson') end + describe 'When the incoming WhatsApp message only has BSUID identifiers' 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 'creates a contact and conversation without a phone number' do + params = { + SmsSid: 'SMxx', + From: 'whatsapp:IN.2081978709342942', + AccountSid: 'ACxxx', + MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid, + Body: 'testing bsuid', + ProfileName: 'Muhsin', + ProfileUsername: 'muhsin', + ExternalUserId: 'IN.2081978709342942', + ParentExternalUserId: 'IN.ENT.9081726354' + } + + described_class.new(params: params).perform + + contact_inbox = whatsapp_twilio_channel.inbox.contact_inboxes.find_by!(source_id: 'whatsapp:IN.2081978709342942') + contact = contact_inbox.contact + parent_contact_inbox = whatsapp_twilio_channel.inbox.contact_inboxes.find_by!(source_id: 'whatsapp:IN.ENT.9081726354') + expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1) + expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('testing bsuid') + expect(contact).to have_attributes(name: 'Muhsin', phone_number: nil) + expect(contact.additional_attributes).to include( + 'social_whatsapp_user_name' => 'muhsin', + 'social_profiles' => { 'whatsapp' => 'muhsin' } + ) + expect(parent_contact_inbox.contact).to eq(contact) + end + + it 'uses the BSUID without the provider prefix as the fallback contact name' do + params = { + SmsSid: 'SMxx', + From: 'whatsapp:IN.2081978709342942', + AccountSid: 'ACxxx', + MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid, + Body: 'testing bsuid', + ExternalUserId: 'IN.2081978709342942' + } + + described_class.new(params: params).perform + + expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('IN.2081978709342942') + end + + it 'links phone and BSUID source ids to the same contact' do + phone_with_bsuid_params = { + SmsSid: 'SMxx1', + From: 'whatsapp:+919745786257', + AccountSid: 'ACxxx', + MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid, + Body: 'phone and bsuid', + ProfileName: 'Muhsin', + ExternalUserId: 'IN.2081978709342942' + } + bsuid_only_params = { + SmsSid: 'SMxx2', + From: 'whatsapp:IN.2081978709342942', + AccountSid: 'ACxxx', + MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid, + Body: 'bsuid only', + ExternalUserId: 'IN.2081978709342942' + } + + described_class.new(params: phone_with_bsuid_params).perform + contact_inbox = whatsapp_twilio_channel.inbox.contact_inboxes.find_by!(source_id: 'whatsapp:+919745786257') + bsuid_contact_inbox = whatsapp_twilio_channel.inbox.contact_inboxes.find_by!(source_id: 'whatsapp:IN.2081978709342942') + + expect { described_class.new(params: bsuid_only_params).perform }.not_to raise_error + expect(whatsapp_twilio_channel.inbox.contact_inboxes.count).to eq(2) + expect(whatsapp_twilio_channel.inbox.messages.pluck(:content)).to contain_exactly('phone and bsuid', 'bsuid only') + expect(bsuid_contact_inbox.contact).to eq(contact_inbox.contact) + end + + it 'backfills contact phone number when a phone arrives after BSUID-only creation' do + bsuid_only_params = { + SmsSid: 'SMxx1', + From: 'whatsapp:IN.2081978709342942', + AccountSid: 'ACxxx', + MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid, + Body: 'bsuid first', + ExternalUserId: 'IN.2081978709342942' + } + phone_with_bsuid_params = { + SmsSid: 'SMxx2', + From: 'whatsapp:+919745786257', + AccountSid: 'ACxxx', + MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid, + Body: 'phone follow up', + ProfileName: 'Muhsin', + ExternalUserId: 'IN.2081978709342942' + } + + described_class.new(params: bsuid_only_params).perform + bsuid_contact_inbox = whatsapp_twilio_channel.inbox.contact_inboxes.find_by!(source_id: 'whatsapp:IN.2081978709342942') + + described_class.new(params: phone_with_bsuid_params).perform + + phone_contact_inbox = whatsapp_twilio_channel.inbox.contact_inboxes.find_by!(source_id: 'whatsapp:+919745786257') + expect(phone_contact_inbox.contact).to eq(bsuid_contact_inbox.contact) + expect(bsuid_contact_inbox.contact.reload.phone_number).to eq('+919745786257') + 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', diff --git a/spec/services/whatsapp/incoming_message_service_spec.rb b/spec/services/whatsapp/incoming_message_service_spec.rb index 2ecf60acb7c..dbaea621c26 100644 --- a/spec/services/whatsapp/incoming_message_service_spec.rb +++ b/spec/services/whatsapp/incoming_message_service_spec.rb @@ -86,6 +86,110 @@ describe Whatsapp::IncomingMessageService do described_class.new(inbox: whatsapp_channel.inbox, params: params).perform expect(whatsapp_channel.inbox.messages.count).to eq(1) end + + it 'creates a contact and conversation when only BSUID is present' do + params = { + 'contacts' => [{ + 'profile' => { 'name' => 'Muhsin', 'username' => 'muhsin' }, + 'user_id' => 'IN.2081978709342942', + 'parent_user_id' => 'IN.ENT.9081726354' + }], + 'messages' => [{ + 'from_user_id' => 'IN.2081978709342942', + 'from_parent_user_id' => 'IN.ENT.9081726354', + 'id' => 'wamid.bsuid-only-message', + 'text' => { 'body' => 'testing bsuid' }, + 'timestamp' => '1778579582', + 'type' => 'text' + }] + }.with_indifferent_access + + described_class.new(inbox: whatsapp_channel.inbox, params: params).perform + + contact_inbox = whatsapp_channel.inbox.contact_inboxes.find_by!(source_id: 'IN.2081978709342942') + contact = contact_inbox.contact + parent_contact_inbox = whatsapp_channel.inbox.contact_inboxes.find_by!(source_id: 'IN.ENT.9081726354') + expect(whatsapp_channel.inbox.conversations.count).to eq(1) + expect(whatsapp_channel.inbox.messages.first.content).to eq('testing bsuid') + expect(contact).to have_attributes(name: 'Muhsin', phone_number: nil) + expect(contact.additional_attributes).to include( + 'social_whatsapp_user_name' => 'muhsin', + 'social_profiles' => { 'whatsapp' => 'muhsin' } + ) + expect(parent_contact_inbox.contact).to eq(contact) + end + + it 'links phone and BSUID source ids to the same contact' do + phone_with_bsuid_params = { + 'contacts' => [{ 'profile' => { 'name' => 'Muhsin' }, 'wa_id' => '919745786257', 'user_id' => 'IN.2081978709342942' }], + 'messages' => [{ + 'from' => '919745786257', + 'from_user_id' => 'IN.2081978709342942', + 'id' => 'wamid.phone-bsuid-message', + 'text' => { 'body' => 'phone and bsuid' }, + 'timestamp' => '1778579582', + 'type' => 'text' + }] + }.with_indifferent_access + bsuid_only_params = { + 'contacts' => [{ 'profile' => { 'name' => 'Muhsin' }, 'user_id' => 'IN.2081978709342942' }], + 'messages' => [{ + 'from_user_id' => 'IN.2081978709342942', + 'id' => 'wamid.bsuid-follow-up-message', + 'text' => { 'body' => 'bsuid only' }, + 'timestamp' => '1778579583', + 'type' => 'text' + }] + }.with_indifferent_access + + described_class.new(inbox: whatsapp_channel.inbox, params: phone_with_bsuid_params).perform + contact_inbox = whatsapp_channel.inbox.contact_inboxes.find_by!(source_id: '919745786257') + bsuid_contact_inbox = whatsapp_channel.inbox.contact_inboxes.find_by!(source_id: 'IN.2081978709342942') + + expect { described_class.new(inbox: whatsapp_channel.inbox, params: bsuid_only_params).perform }.not_to raise_error + expect(whatsapp_channel.inbox.contact_inboxes.count).to eq(2) + expect(whatsapp_channel.inbox.messages.pluck(:content)).to contain_exactly('phone and bsuid', 'bsuid only') + expect(bsuid_contact_inbox.contact).to eq(contact_inbox.contact) + end + + it 'backfills contact phone number when a phone arrives after BSUID-only creation' do + bsuid_only_params = { + 'contacts' => [{ 'profile' => { 'name' => 'Muhsin' }, 'user_id' => 'IN.2081978709342942' }], + 'messages' => [{ + 'from_user_id' => 'IN.2081978709342942', + 'id' => 'wamid.bsuid-first-message', + 'text' => { 'body' => 'bsuid first' }, + 'timestamp' => '1778579582', + 'type' => 'text' + }] + }.with_indifferent_access + phone_with_bsuid_params = { + 'contacts' => [{ 'profile' => { 'name' => 'Muhsin' }, 'wa_id' => '919745786257', 'user_id' => 'IN.2081978709342942' }], + 'messages' => [{ + 'from' => '919745786257', + 'from_user_id' => 'IN.2081978709342942', + 'id' => 'wamid.phone-follow-up-message', + 'text' => { 'body' => 'phone follow up' }, + 'timestamp' => '1778579583', + 'type' => 'text' + }] + }.with_indifferent_access + + described_class.new(inbox: whatsapp_channel.inbox, params: bsuid_only_params).perform + bsuid_contact_inbox = whatsapp_channel.inbox.contact_inboxes.find_by!(source_id: 'IN.2081978709342942') + + described_class.new(inbox: whatsapp_channel.inbox, params: phone_with_bsuid_params).perform + + phone_contact_inbox = whatsapp_channel.inbox.contact_inboxes.find_by!(source_id: '919745786257') + expect(phone_contact_inbox.contact).to eq(bsuid_contact_inbox.contact) + expect(bsuid_contact_inbox.contact.reload.phone_number).to eq('+919745786257') + end + + it 'keeps cloud BSUID source ids in the Meta-provided shape' do + service = described_class.new(inbox: whatsapp_channel.inbox, params: params) + + expect(service.send(:whatsapp_source_id, 'whatsapp:IN.2081978709342942')).to eq('whatsapp:IN.2081978709342942') + end end context 'when unsupported message types' do @@ -145,6 +249,24 @@ describe Whatsapp::IncomingMessageService do expect(message.reload.status).to eq('read') end + it 'stores BSUID source ids from status contacts' do + bsuid = 'IN.2081978709342942' + parent_bsuid = 'IN.ENT.9081726354' + status_params = { + 'contacts' => [{ 'wa_id' => from, 'user_id' => bsuid, 'parent_user_id' => parent_bsuid }], + 'statuses' => [{ 'recipient_id' => from, 'id' => from, 'status' => 'delivered' }] + }.with_indifferent_access + + described_class.new(inbox: whatsapp_channel.inbox, params: status_params).perform + + source_rows = whatsapp_channel.inbox.contact_inboxes.where(source_id: [from, bsuid, parent_bsuid]).pluck(:source_id, :contact_id) + expect(source_rows).to contain_exactly( + [from, contact_inbox.contact_id], + [bsuid, contact_inbox.contact_id], + [parent_bsuid, contact_inbox.contact_id] + ) + end + it 'update status message to failed' do status_params = { 'statuses' => [{ 'recipient_id' => from, 'id' => from, 'status' => 'failed', diff --git a/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb b/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb index 4b684181196..70c29c0928a 100644 --- a/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb +++ b/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb @@ -97,6 +97,98 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do end end + context 'when BSUID identifiers are present' do + it 'creates a contact and conversation when only BSUID is present' do + bsuid_params = { + phone_number: whatsapp_channel.phone_number, + object: 'whatsapp_business_account', + entry: [{ + changes: [{ + value: { + contacts: [{ + profile: { name: 'Muhsin', username: 'muhsin' }, + user_id: 'IN.2081978709342942', + parent_user_id: 'IN.ENT.9081726354' + }], + messages: [{ + from_user_id: 'IN.2081978709342942', + from_parent_user_id: 'IN.ENT.9081726354', + id: 'wamid.cloud-bsuid-only-message', + text: { body: 'testing bsuid' }, + timestamp: '1778579582', + type: 'text' + }] + } + }] + }] + }.with_indifferent_access + + described_class.new(inbox: whatsapp_channel.inbox, params: bsuid_params).perform + + contact_inbox = whatsapp_channel.inbox.contact_inboxes.find_by!(source_id: 'IN.2081978709342942') + contact = contact_inbox.contact + parent_contact_inbox = whatsapp_channel.inbox.contact_inboxes.find_by!(source_id: 'IN.ENT.9081726354') + + expect(whatsapp_channel.inbox.conversations.count).to eq(1) + expect(whatsapp_channel.inbox.messages.first.content).to eq('testing bsuid') + expect(contact).to have_attributes(name: 'Muhsin', phone_number: nil) + expect(contact.additional_attributes).to include( + 'social_whatsapp_user_name' => 'muhsin', + 'social_profiles' => { 'whatsapp' => 'muhsin' } + ) + expect(parent_contact_inbox.contact).to eq(contact) + end + + it 'links phone and BSUID source ids to the same contact' do + phone_with_bsuid_params = { + phone_number: whatsapp_channel.phone_number, + object: 'whatsapp_business_account', + entry: [{ + changes: [{ + value: { + contacts: [{ profile: { name: 'Muhsin' }, wa_id: '919745786257', user_id: 'IN.2081978709342942' }], + messages: [{ + from: '919745786257', + from_user_id: 'IN.2081978709342942', + id: 'wamid.cloud-phone-bsuid-message', + text: { body: 'phone and bsuid' }, + timestamp: '1778579582', + type: 'text' + }] + } + }] + }] + }.with_indifferent_access + bsuid_only_params = { + phone_number: whatsapp_channel.phone_number, + object: 'whatsapp_business_account', + entry: [{ + changes: [{ + value: { + contacts: [{ profile: { name: 'Muhsin' }, user_id: 'IN.2081978709342942' }], + messages: [{ + from_user_id: 'IN.2081978709342942', + id: 'wamid.cloud-bsuid-follow-up-message', + text: { body: 'bsuid only' }, + timestamp: '1778579583', + type: 'text' + }] + } + }] + }] + }.with_indifferent_access + + described_class.new(inbox: whatsapp_channel.inbox, params: phone_with_bsuid_params).perform + contact_inbox = whatsapp_channel.inbox.contact_inboxes.find_by!(source_id: '919745786257') + bsuid_contact_inbox = whatsapp_channel.inbox.contact_inboxes.find_by!(source_id: 'IN.2081978709342942') + + expect { described_class.new(inbox: whatsapp_channel.inbox, params: bsuid_only_params).perform }.not_to raise_error + expect(whatsapp_channel.inbox.contact_inboxes.count).to eq(2) + expect(whatsapp_channel.inbox.messages.pluck(:content)).to contain_exactly('phone and bsuid', 'bsuid only') + expect(bsuid_contact_inbox.contact).to eq(contact_inbox.contact) + end + end + context 'when invalid params' do it 'will not throw error' do described_class.new(inbox: whatsapp_channel.inbox, params: { phone_number: whatsapp_channel.phone_number,