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:
Muhsin Keloth
2026-09-03 10:15:52 +04:00
committed by GitHub
parent bfd9d9e0ae
commit 9550de83e4
14 changed files with 269 additions and 21 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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?

View File

@@ -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

View File

@@ -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)

View File

@@ -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?

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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)

View File

@@ -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',

View File

@@ -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

View File

@@ -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