Compare commits

...
Author SHA1 Message Date
Muhsin 466d01aec4 fix(whatsapp): reuse Brazil contacts for Twilio 2026-07-22 15:12:33 +04:00
Muhsin 64ec41cc86 fix(whatsapp): handle partial Brazil phone numbers 2026-07-22 15:01:43 +04:00
Muhsin KelothandGitHub df1658a4f0 Merge branch 'develop' into fix/whatsapp-coexistence-brazil-duplicate-contacts 2026-07-22 14:13:06 +04:00
Muhsin cb2d8bd37a fix(whatsapp): reuse safe phone candidates across events 2026-07-22 14:12:26 +04:00
Muhsin 75eebb877c fix(whatsapp): preserve safe contact number matching 2026-07-22 13:24:56 +04:00
Muhsin KelothandGitHub 7e0f1cf29e Merge branch 'develop' into fix/whatsapp-coexistence-brazil-duplicate-contacts 2026-07-22 12:59:10 +04:00
Muhsin KelothandGitHub 513d9de7fb Merge branch 'develop' into fix/whatsapp-coexistence-brazil-duplicate-contacts 2026-06-22 12:57:08 +04:00
Muhsin KelothandGitHub 22dea1959f Merge branch 'develop' into fix/whatsapp-coexistence-brazil-duplicate-contacts 2026-06-11 17:14:33 +04:00
MuhsinandClaude Opus 4.8 fe4ba71cdf fix(whatsapp): normalize contact phone numbers to prevent Brazil duplicates
WhatsApp Coexistence echo messages from the native WhatsApp app deliver
Brazilian numbers without the mobile 9th digit. The contact-resolution
layer only deduplicated at the contact_inbox source_id level, so when no
matching contact_inbox existed it fell back to an exact phone_number match
using the un-normalized number, missing the existing contact stored with
the 9th digit and creating a duplicate.

Normalize the phone number used for contact lookup/creation via the
existing Brazil/Argentina normalizers so both formats resolve to the same
contact. Non-affected countries are returned unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:03:27 +04:00
13 changed files with 245 additions and 16 deletions
@@ -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
@@ -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?
@@ -217,8 +217,10 @@ class Whatsapp::IncomingMessageBaseService
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
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
@@ -78,7 +78,19 @@ module Whatsapp::IncomingMessageIdentifierHelper
formatted_phone_number = "+#{phone_number}"
display_name = name == phone_identifier ? formatted_phone_number : name
{ name: display_name, phone_number: formatted_phone_number }
attributes = { name: display_name, phone_number: formatted_phone_number }
candidates = phone_number_candidates(phone_number).drop(1).map { |candidate| "+#{candidate}" }
attributes[:phone_number_candidates] = candidates if candidates.present?
attributes
end
def phone_number_candidates(phone_number)
phone_number_normalization_service.phone_number_candidates(phone_number)
end
def phone_number_normalization_service
@phone_number_normalization_service ||= Whatsapp::PhoneNumberNormalizationService.new(inbox)
end
def update_whatsapp_identifiers(source_ids: [], username: nil, phone_number: nil)
@@ -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
@@ -30,7 +30,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.
@@ -148,6 +148,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)
@@ -670,6 +670,24 @@ 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 'creates contact inbox with incoming number when no existing contact' do
params = {
SmsSid: 'SMxx',
@@ -374,6 +374,135 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
end
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
end
# Métodos auxiliares para reduzir o tamanho do exemplo
@@ -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