fix(whatsapp): prevent duplicate brazil contacts in whatsapp co-existence (#14657)
When using WhatsApp Coexistence, a message sent from the native WhatsApp app can create a duplicate contact and a separate conversation for a customer who already exists in Chatwoot. This happens for Brazilian numbers when WhatsApp delivers the number without the mobile 9th digit while the existing contact is stored with the normalized number. Chatwoot previously deduplicated incoming WhatsApp identifiers mostly at the `contact_inbox.source_id` level. When no matching `contact_inbox` existed, contact creation fell back to an exact `phone_number` lookup using the raw provider number, so an existing normalized contact could be missed. This change makes contact lookup try safe phone-number candidates so the same customer resolves to the existing contact and conversation history stays intact. ### Closes - https://linear.app/chatwoot/issue/CW-6724/duplicate-contacts-created-in-brazil-9th-digit-issue-when-using - Closes https://github.com/chatwoot/chatwoot/issues/13986 ### How to reproduce 1. Enable WhatsApp Coexistence on a self-hosted instance. 2. Ensure a contact exists with a Brazilian number including the mobile 9th digit, for example `+5511912345678`. 3. From the native WhatsApp Business app, send a message to that customer so Chatwoot receives an echo event with the number without the 9th digit, for example `+551112345678`. 4. Before this change, Chatwoot creates a duplicate contact and a separate conversation. 5. After this change, Chatwoot reuses the existing contact and the message lands in the existing conversation context. ### Things to know - Exact phone-number matches still win before generated candidates, so existing contacts stored in the provider-delivered format are preserved. - Brazil candidate matching is limited to legacy mobile ranges so landlines are not rewritten into mobile-looking numbers. - The same contact candidate logic is applied to WhatsApp Cloud, WhatsApp Coexistence echo events, Twilio WhatsApp, and Enterprise WhatsApp calls. ### What changed - Added safe contact phone-number candidate lookup to `ContactInboxWithContactBuilder`. - Centralized candidate generation through `Whatsapp::PhoneNumberNormalizationService#phone_number_candidates`. - Added Brazil-specific contact candidate rules and regression coverage for raw, normalized, landline, profile-name, Twilio, and Enterprise call paths. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Tanmay Deep Sharma <tanmaydeepsharma21@gmail.com> Co-authored-by: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
This commit is contained in:
@@ -67,7 +67,7 @@ class ContactInboxWithContactBuilder
|
||||
def find_contact
|
||||
contact = find_contact_by_identifier(contact_attributes[:identifier])
|
||||
contact ||= find_contact_by_email(contact_attributes[:email])
|
||||
contact ||= find_contact_by_phone_number(contact_attributes[:phone_number])
|
||||
contact ||= find_contact_by_phone_numbers
|
||||
contact ||= find_contact_by_instagram_source_id(source_id) if instagram_channel?
|
||||
|
||||
contact
|
||||
@@ -107,9 +107,14 @@ class ContactInboxWithContactBuilder
|
||||
account.contacts.from_email(email)
|
||||
end
|
||||
|
||||
def find_contact_by_phone_number(phone_number)
|
||||
return if phone_number.blank?
|
||||
def find_contact_by_phone_numbers
|
||||
phone_numbers = [contact_attributes[:phone_number], *Array(contact_attributes[:phone_number_candidates])].compact_blank.uniq
|
||||
|
||||
account.contacts.find_by(phone_number: phone_number)
|
||||
phone_numbers.each do |phone_number|
|
||||
contact = account.contacts.find_by(phone_number: phone_number)
|
||||
return contact if contact
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -119,7 +119,7 @@ class Twilio::IncomingMessageService
|
||||
{
|
||||
name: contact_name,
|
||||
phone_number: phone_number.presence,
|
||||
additional_attributes: additional_attributes
|
||||
additional_attributes: additional_attributes, phone_number_candidates: twilio_whatsapp_phone_number_candidates
|
||||
}
|
||||
end
|
||||
|
||||
@@ -191,7 +191,7 @@ class Twilio::IncomingMessageService
|
||||
return if params[:ProfileName].blank?
|
||||
return if @contact.name == params[:ProfileName]
|
||||
|
||||
# Only update if current name exactly matches the phone number or formatted phone number
|
||||
# Only update if current name exactly matches a phone number candidate
|
||||
return unless contact_name_matches_phone_number?
|
||||
|
||||
@contact.update!(name: params[:ProfileName])
|
||||
@@ -200,6 +200,16 @@ class Twilio::IncomingMessageService
|
||||
def contact_name_matches_phone_number?
|
||||
return false if phone_number.blank?
|
||||
|
||||
@contact.name == phone_number || @contact.name == formatted_phone_number
|
||||
phone_name_candidates.include?(@contact.name)
|
||||
end
|
||||
|
||||
def phone_name_candidates
|
||||
[phone_number, formatted_phone_number, *formatted_twilio_whatsapp_phone_number_candidates].compact_blank.uniq
|
||||
end
|
||||
|
||||
def formatted_twilio_whatsapp_phone_number_candidates
|
||||
Array(twilio_whatsapp_phone_number_candidates).flat_map do |candidate|
|
||||
[candidate, TelephoneNumber.parse(candidate).international_number]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -41,6 +41,13 @@ module Twilio::WhatsappIdentifierHelper
|
||||
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider("whatsapp:#{phone_number}", :twilio)
|
||||
end
|
||||
|
||||
def twilio_whatsapp_phone_number_candidates
|
||||
return unless twilio_channel.whatsapp? && phone_number.present?
|
||||
|
||||
candidates = Whatsapp::PhoneNumberNormalizationService.new(inbox).phone_number_candidates(phone_number.delete_prefix('+')).drop(1)
|
||||
candidates.map { |candidate| "+#{candidate}" }.presence
|
||||
end
|
||||
|
||||
def twilio_whatsapp_source_id(identifier)
|
||||
identifier = identifier.to_s
|
||||
return if identifier.blank?
|
||||
|
||||
@@ -227,19 +227,20 @@ class Whatsapp::IncomingMessageBaseService
|
||||
return if profile_name.blank?
|
||||
return if @contact.name == profile_name
|
||||
|
||||
# Only update if current name exactly matches the phone number or formatted phone number
|
||||
# Only update if current name exactly matches a phone number candidate
|
||||
return unless contact_name_matches_phone_number?
|
||||
|
||||
@contact.update!(name: profile_name)
|
||||
end
|
||||
|
||||
def contact_name_matches_phone_number?
|
||||
message_phone_number = whatsapp_phone_number(messages_data.first[:from])
|
||||
return false if message_phone_number.blank?
|
||||
return false if (message_phone_number = whatsapp_phone_number(messages_data.first[:from])).blank?
|
||||
|
||||
phone_number = "+#{message_phone_number}"
|
||||
formatted_phone_number = TelephoneNumber.parse(phone_number).international_number
|
||||
@contact.name == phone_number || @contact.name == formatted_phone_number
|
||||
phone_number_candidates(message_phone_number).any? do |number|
|
||||
phone_number = "+#{number}"
|
||||
formatted_phone_number = TelephoneNumber.parse(phone_number).international_number
|
||||
@contact.name == phone_number || @contact.name == formatted_phone_number
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -99,8 +99,9 @@ module Whatsapp::IncomingMessageIdentifierHelper
|
||||
return { name: name } if phone_number.blank?
|
||||
|
||||
formatted_phone_number = "+#{phone_number}"
|
||||
candidates = phone_number_candidates(phone_number).drop(1).map { |candidate| "+#{candidate}" }
|
||||
display_name = name == phone_identifier ? formatted_phone_number : name
|
||||
{ name: display_name, phone_number: formatted_phone_number }
|
||||
{ name: display_name, phone_number: formatted_phone_number, phone_number_candidates: candidates.presence }.compact
|
||||
end
|
||||
|
||||
def update_whatsapp_identifiers(source_ids: [], username: nil, phone_number: nil)
|
||||
|
||||
@@ -60,6 +60,10 @@ module Whatsapp::IncomingMessageServiceHelpers
|
||||
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider(waid, :cloud)
|
||||
end
|
||||
|
||||
def phone_number_candidates(phone_number)
|
||||
Whatsapp::PhoneNumberNormalizationService.new(inbox).phone_number_candidates(phone_number)
|
||||
end
|
||||
|
||||
def whatsapp_phone_number(identifier)
|
||||
identifier = identifier.to_s
|
||||
return if identifier.blank?
|
||||
|
||||
@@ -11,6 +11,12 @@ class Whatsapp::PhoneNormalizers::BasePhoneNormalizer
|
||||
raise NotImplementedError, 'Subclasses must implement #normalize'
|
||||
end
|
||||
|
||||
# Contact matching needs stronger safety than source-id lookup because two
|
||||
# valid phone numbers can share digits across country-specific formats.
|
||||
def contact_candidates(waid)
|
||||
[waid]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def country_code_pattern
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
# Handles Brazil phone number normalization
|
||||
# ref: https://github.com/chatwoot/chatwoot/issues/5840
|
||||
# ref: https://www.gov.br/anatel/pt-br/regulado/numeracao/perguntas-frequentes
|
||||
#
|
||||
# Brazil changed its mobile number system by adding a "9" prefix to existing numbers.
|
||||
# This normalizer adds the "9" digit if the number is 12 digits (making it 13 digits total)
|
||||
# to match the new format: 55 + DDD + 9 + number
|
||||
# This normalizer adds the "9" only to legacy eight-digit mobile ranges, leaving
|
||||
# eight-digit landlines (which start with 2-5) unchanged.
|
||||
class Whatsapp::PhoneNormalizers::BrazilPhoneNormalizer < Whatsapp::PhoneNormalizers::BasePhoneNormalizer
|
||||
COUNTRY_CODE_LENGTH = 2
|
||||
DDD_LENGTH = 2
|
||||
LEGACY_MOBILE_NUMBER_PATTERN = /\A[6-9]\d{7}\z/
|
||||
|
||||
def normalize(waid)
|
||||
return waid unless handles_country?(waid)
|
||||
|
||||
ddd = waid[COUNTRY_CODE_LENGTH, DDD_LENGTH]
|
||||
number = waid[COUNTRY_CODE_LENGTH + DDD_LENGTH, waid.length - (COUNTRY_CODE_LENGTH + DDD_LENGTH)]
|
||||
normalized_number = "55#{ddd}#{number}"
|
||||
normalized_number = "55#{ddd}9#{number}" if normalized_number.length != 13
|
||||
normalized_number
|
||||
number = waid[(COUNTRY_CODE_LENGTH + DDD_LENGTH)..].to_s
|
||||
return waid unless number.match?(LEGACY_MOBILE_NUMBER_PATTERN)
|
||||
|
||||
"55#{ddd}9#{number}"
|
||||
end
|
||||
|
||||
def contact_candidates(waid)
|
||||
[waid, normalize(waid)].uniq
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -29,6 +29,17 @@ class Whatsapp::PhoneNumberNormalizationService
|
||||
existing_contact_inbox&.source_id || raw_number
|
||||
end
|
||||
|
||||
# Keep the provider value first so exact contact matches always win. Each
|
||||
# country normalizer explicitly opts into contact-safe alternatives; source-id
|
||||
# normalization alone is not enough because the alternate may be another
|
||||
# valid number (for example an Argentina landline without the mobile 9).
|
||||
def phone_number_candidates(clean_number)
|
||||
normalizer = find_normalizer_for_country(clean_number)
|
||||
return [clean_number] unless normalizer
|
||||
|
||||
normalizer.contact_candidates(clean_number)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :inbox
|
||||
|
||||
@@ -35,7 +35,14 @@ class Whatsapp::InboundCallIdentityBuilder
|
||||
return { name: name } unless phone.to_s.match?(/\A\d{1,15}\z/)
|
||||
|
||||
formatted = "+#{phone}"
|
||||
{ name: name == phone ? formatted : name, phone_number: formatted }
|
||||
attributes = { name: name == phone ? formatted : name, phone_number: formatted }
|
||||
candidates = phone_number_normalization_service.phone_number_candidates(phone.to_s).drop(1).map { |candidate| "+#{candidate}" }
|
||||
attributes[:phone_number_candidates] = candidates if candidates.present?
|
||||
attributes
|
||||
end
|
||||
|
||||
def phone_number_normalization_service
|
||||
@phone_number_normalization_service ||= Whatsapp::PhoneNumberNormalizationService.new(inbox)
|
||||
end
|
||||
|
||||
# Match the contacts entry to THIS caller so batched payloads don't borrow another's identity.
|
||||
|
||||
@@ -151,6 +151,23 @@ describe Whatsapp::IncomingCallService do
|
||||
expect(Call.last.conversation.contact_inbox).to eq(existing)
|
||||
end
|
||||
|
||||
it 'reuses a normalized phone contact when no ContactInbox exists yet' do
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
contact = create(:contact, account: account, phone_number: '+5541988887777')
|
||||
|
||||
params = {
|
||||
calls: [{ id: provider_call_id, from: '554188887777', event: 'connect',
|
||||
session: { sdp: sdp_offer, sdp_type: 'offer' } }],
|
||||
contacts: [{ wa_id: '554188887777' }]
|
||||
}
|
||||
|
||||
expect { described_class.new(inbox: inbox, params: params).perform }
|
||||
.to change(Call, :count).by(1).and not_change(Contact, :count)
|
||||
|
||||
expect(Call.last.contact).to eq(contact)
|
||||
expect(Call.last.conversation.contact_inbox).to have_attributes(contact: contact, source_id: '554188887777')
|
||||
end
|
||||
|
||||
it 'reuses the BSUID-keyed ContactInbox messaging created for a username-only caller' do
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
contact = create(:contact, account: account)
|
||||
|
||||
@@ -761,6 +761,41 @@ describe Twilio::IncomingMessageService do
|
||||
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+5541988887777')
|
||||
end
|
||||
|
||||
it 'reuses an existing contact in new format when the inbox has no contact inbox' do
|
||||
existing_contact = create(:contact, account: account, phone_number: '+5541988887777')
|
||||
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: 'whatsapp:+554188887777',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
|
||||
Body: 'Test message from Brazil',
|
||||
ProfileName: 'João Silva'
|
||||
}
|
||||
|
||||
expect { described_class.new(params: params).perform }.not_to change(account.contacts, :count)
|
||||
|
||||
contact_inbox = whatsapp_twilio_channel.inbox.contact_inboxes.find_by!(source_id: 'whatsapp:+554188887777')
|
||||
expect(contact_inbox.contact).to eq(existing_contact)
|
||||
end
|
||||
|
||||
it 'updates a normalized phone placeholder with the profile name' do
|
||||
existing_contact = create(:contact, account: account, name: '+5541988887777', phone_number: '+5541988887777')
|
||||
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: 'whatsapp:+554188887777',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
|
||||
Body: 'Test message from Brazil',
|
||||
ProfileName: 'João Silva'
|
||||
}
|
||||
|
||||
expect { described_class.new(params: params).perform }.not_to change(account.contacts, :count)
|
||||
|
||||
expect(existing_contact.reload.name).to eq('João Silva')
|
||||
end
|
||||
|
||||
it 'creates contact inbox with incoming number when no existing contact' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
|
||||
@@ -746,6 +746,135 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an echo (coexistence) message arrives for a Brazil number missing the 9th digit' do
|
||||
let(:echo_phone_number) { '554188887777' }
|
||||
let(:echo_params) do
|
||||
{
|
||||
object: 'whatsapp_business_account',
|
||||
entry: [{
|
||||
changes: [{
|
||||
field: 'smb_message_echoes',
|
||||
value: {
|
||||
message_echoes: [{
|
||||
from: whatsapp_channel.phone_number.delete('+'),
|
||||
to: echo_phone_number,
|
||||
id: 'wamid.ECHO_MESSAGE_ID',
|
||||
timestamp: '1664799904',
|
||||
text: { body: 'Reply from the WhatsApp app' },
|
||||
type: 'text'
|
||||
}]
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
it 'reuses the existing contact stored with the normalized phone number instead of duplicating it' do
|
||||
existing_contact = create(:contact, phone_number: '+5541988887777', account: whatsapp_channel.account)
|
||||
|
||||
expect do
|
||||
described_class.new(inbox: whatsapp_channel.inbox, params: echo_params, outgoing_echo: true).perform
|
||||
end.not_to change(Contact, :count)
|
||||
|
||||
conversation = whatsapp_channel.inbox.conversations.last
|
||||
expect(conversation.contact).to eq(existing_contact)
|
||||
expect(conversation.messages.last.content).to eq('Reply from the WhatsApp app')
|
||||
end
|
||||
|
||||
it 'prefers an existing contact stored with the raw phone number' do
|
||||
existing_contact = create(:contact, phone_number: '+554188887777', account: whatsapp_channel.account)
|
||||
|
||||
expect do
|
||||
described_class.new(inbox: whatsapp_channel.inbox, params: echo_params, outgoing_echo: true).perform
|
||||
end.not_to change(Contact, :count)
|
||||
|
||||
expect(whatsapp_channel.inbox.conversations.last.contact).to eq(existing_contact)
|
||||
end
|
||||
|
||||
context 'when the number is a valid landline' do
|
||||
let(:echo_phone_number) { '554132345678' }
|
||||
|
||||
it 'does not match a mobile contact created by adding a ninth digit' do
|
||||
mobile_contact = create(:contact, phone_number: '+5541932345678', account: whatsapp_channel.account)
|
||||
|
||||
expect do
|
||||
described_class.new(inbox: whatsapp_channel.inbox, params: echo_params, outgoing_echo: true).perform
|
||||
end.to change(Contact, :count).by(1)
|
||||
|
||||
contact = whatsapp_channel.inbox.conversations.last.contact
|
||||
expect(contact).not_to eq(mobile_contact)
|
||||
expect(contact.phone_number).to eq('+554132345678')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an incoming Brazil message supplies a profile name after normalized contact matching' do
|
||||
let(:incoming_params) do
|
||||
{
|
||||
object: 'whatsapp_business_account',
|
||||
entry: [{
|
||||
changes: [{
|
||||
value: {
|
||||
contacts: [{ profile: { name: 'Maria Silva' }, wa_id: '554188887777' }],
|
||||
messages: [{
|
||||
from: '554188887777',
|
||||
id: 'wamid.BRAZIL_PROFILE_NAME',
|
||||
timestamp: '1664799904',
|
||||
text: { body: 'Hello' },
|
||||
type: 'text'
|
||||
}]
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
it 'replaces a normalized phone-number placeholder with the profile name' do
|
||||
existing_contact = create(:contact, name: '+5541988887777', phone_number: '+5541988887777', account: whatsapp_channel.account)
|
||||
|
||||
expect do
|
||||
described_class.new(inbox: whatsapp_channel.inbox, params: incoming_params).perform
|
||||
end.not_to change(Contact, :count)
|
||||
|
||||
expect(existing_contact.reload.name).to eq('Maria Silva')
|
||||
expect(whatsapp_channel.inbox.conversations.last.contact).to eq(existing_contact)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an incoming Argentina number is already in the canonical form' do
|
||||
let(:argentina_params) do
|
||||
{
|
||||
object: 'whatsapp_business_account',
|
||||
entry: [{
|
||||
changes: [{
|
||||
value: {
|
||||
contacts: [{ profile: { name: 'Buenos Aires Office' }, wa_id: '541123456789' }],
|
||||
messages: [{
|
||||
from: '541123456789',
|
||||
id: 'wamid.ARGENTINA_LANDLINE',
|
||||
timestamp: '1664799904',
|
||||
text: { body: 'Hello' },
|
||||
type: 'text'
|
||||
}]
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
it 'does not infer a mobile 9 candidate for a valid landline' do
|
||||
mobile_contact = create(:contact, phone_number: '+5491123456789', account: whatsapp_channel.account)
|
||||
|
||||
expect do
|
||||
described_class.new(inbox: whatsapp_channel.inbox, params: argentina_params).perform
|
||||
end.to change(Contact, :count).by(1)
|
||||
|
||||
contact = whatsapp_channel.inbox.conversations.last.contact
|
||||
expect(contact).not_to eq(mobile_contact)
|
||||
expect(contact.phone_number).to eq('+541123456789')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an identity-change system message is received' do
|
||||
let(:contact) { create(:contact, account: whatsapp_channel.inbox.account) }
|
||||
let(:system) do
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::PhoneNormalizers::BrazilPhoneNormalizer do
|
||||
describe '#contact_candidates' do
|
||||
it 'preserves a partial Brazil number without raising' do
|
||||
expect(described_class.new.contact_candidates('55')).to eq(['55'])
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user