fix(whatsapp): preserve safe contact number matching

This commit is contained in:
Muhsin
2026-07-22 13:24:56 +04:00
parent 7e0f1cf29e
commit 75eebb877c
5 changed files with 89 additions and 18 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
@@ -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
[message_phone_number, normalized_phone_number(message_phone_number)].uniq.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
@@ -76,11 +76,13 @@ module Whatsapp::IncomingMessageIdentifierHelper
phone_number = whatsapp_phone_number(phone_identifier)
return { name: name } if phone_number.blank?
# Normalize before storing/matching so country-specific format variations (e.g. Brazil's
# missing 9th digit) resolve to the same contact instead of creating duplicates.
formatted_phone_number = "+#{normalized_phone_number(phone_number)}"
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 }
normalized_number = normalized_phone_number(phone_number)
attributes[:phone_number_candidates] = ["+#{normalized_number}"] if normalized_number != phone_number
attributes
end
def normalized_phone_number(phone_number)
@@ -1,21 +1,23 @@
# 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
return waid unless number.match?(LEGACY_MOBILE_NUMBER_PATTERN)
"55#{ddd}9#{number}"
end
private
@@ -376,6 +376,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
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',
@@ -385,7 +386,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
value: {
message_echoes: [{
from: whatsapp_channel.phone_number.delete('+'),
to: '551112345678',
to: echo_phone_number,
id: 'wamid.ECHO_MESSAGE_ID',
timestamp: '1664799904',
text: { body: 'Reply from the WhatsApp app' },
@@ -398,7 +399,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
end
it 'reuses the existing contact stored with the normalized phone number instead of duplicating it' do
existing_contact = create(:contact, phone_number: '+5511912345678', account: whatsapp_channel.account)
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
@@ -408,6 +409,65 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
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
end