mirror of
https://github.com/chatwoot/chatwoot.git
synced 2026-06-04 21:02:35 +08:00
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>
This commit is contained in:
parent
3fae800936
commit
40deaef458
@ -31,7 +31,11 @@ class Twilio::CallbackController < ApplicationController
|
||||
:Latitude,
|
||||
:Longitude,
|
||||
:MessageType,
|
||||
:ProfileName
|
||||
:ProfileName,
|
||||
:ExternalUserId,
|
||||
:ParentExternalUserId,
|
||||
:ProfileUsername,
|
||||
:Username
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@ -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)
|
||||
|
||||
30
app/services/contact_inbox_source_id_resolver.rb
Normal file
30
app/services/contact_inbox_source_id_resolver.rb
Normal file
@ -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
|
||||
@ -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:<BSUID>` 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:<BSUID>`.
|
||||
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
|
||||
|
||||
69
app/services/twilio/whatsapp_identifier_helper.rb
Normal file
69
app/services/twilio/whatsapp_identifier_helper.rb
Normal file
@ -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
|
||||
68
app/services/whatsapp/identifier_sync_service.rb
Normal file
68
app/services/whatsapp/identifier_sync_service.rb
Normal file
@ -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
|
||||
@ -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
|
||||
|
||||
108
app/services/whatsapp/incoming_message_identifier_helper.rb
Normal file
108
app/services/whatsapp/incoming_message_identifier_helper.rb
Normal file
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user