fix(whatsapp): reuse safe phone candidates across events

This commit is contained in:
Muhsin
2026-07-22 14:12:26 +04:00
parent 75eebb877c
commit cb2d8bd37a
8 changed files with 89 additions and 6 deletions
@@ -217,7 +217,7 @@ class Whatsapp::IncomingMessageBaseService
message_phone_number = whatsapp_phone_number(messages_data.first[:from])
return false if message_phone_number.blank?
[message_phone_number, normalized_phone_number(message_phone_number)].uniq.any? do |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
@@ -80,13 +80,17 @@ module Whatsapp::IncomingMessageIdentifierHelper
display_name = name == phone_identifier ? formatted_phone_number : name
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
candidates = phone_number_candidates(phone_number).drop(1).map { |candidate| "+#{candidate}" }
attributes[:phone_number_candidates] = candidates if candidates.present?
attributes
end
def normalized_phone_number(phone_number)
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_number(phone_number)
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
@@ -20,6 +20,10 @@ class Whatsapp::PhoneNormalizers::BrazilPhoneNormalizer < Whatsapp::PhoneNormali
"55#{ddd}9#{number}"
end
def contact_candidates(waid)
[waid, normalize(waid)].uniq
end
private
def country_code_pattern
@@ -38,6 +38,17 @@ class Whatsapp::PhoneNumberNormalizationService
normalizer.normalize(clean_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)
@@ -469,6 +469,40 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
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