Skip to content

Commit 46cf6eb

Browse files
fix(whatsapp): match Brazilian ninth-digit phone variants on inbound consolidation (#319)
A contact saved under the wrong ninth-digit variant (e.g. outbound on_whatsapp normalization was skipped due to a transient provider error) no longer spawns a duplicate contact and conversation when the reply arrives with the canonical number. ContactInboxConsolidationService now matches contact_inboxes and contacts across Brazil/Argentina "9" variants, preferring exact matches, and self-heals duplicates created before the fix by merging them on the next inbound message.
1 parent af9360c commit 46cf6eb

6 files changed

Lines changed: 159 additions & 2 deletions

File tree

app/services/whatsapp/contact_inbox_consolidation_service.rb

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
# 3. If the conversation is deleted/resolved and a new one is created, a new contact_inbox
66
# with source_id = phone is created (since the existing one has LID)
77
# 4. This service consolidates these duplicates when a message arrives
8+
#
9+
# Phone lookups are ninth-digit-variant aware (Brazil/Argentina): a contact saved
10+
# with the "wrong" variant (e.g. outbound on_whatsapp normalization was skipped)
11+
# still matches the canonical number the webhook delivers, instead of spawning a
12+
# duplicate contact and conversation. Exact matches always win over variants.
813
class Whatsapp::ContactInboxConsolidationService
914
def initialize(inbox:, phone:, lid:, identifier:)
1015
@inbox = inbox
@@ -38,7 +43,8 @@ def perform # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComp
3843
private
3944

4045
def find_phone_contact_inbox
41-
@inbox.contact_inboxes.find_by(source_id: @phone)
46+
@inbox.contact_inboxes.find_by(source_id: @phone) ||
47+
(@inbox.contact_inboxes.find_by(source_id: alternate_phone_variants) if alternate_phone_variants.any?)
4248
end
4349

4450
def find_lid_contact_inbox
@@ -100,7 +106,7 @@ def migrate_phone_to_lid(phone_contact_inbox)
100106
# Find contact by phone number and update their contact_inbox source_id to LID
101107
# This handles the case where contact_inbox has a different source_id (e.g., old format)
102108
def update_existing_contact_inbox_by_phone
103-
existing_contact = @inbox.account.contacts.find_by(phone_number: "+#{@phone}")
109+
existing_contact = find_contact_by_phone_variants
104110
return unless existing_contact
105111

106112
existing_contact_inbox = existing_contact.contact_inboxes.find_by(inbox_id: @inbox.id)
@@ -160,6 +166,22 @@ def adopt_lid_contact_inbox(phone_contact, lid_ci)
160166
end
161167
end
162168

169+
def find_contact_by_phone_variants
170+
contacts = @inbox.account.contacts
171+
contacts.find_by(phone_number: "+#{@phone}") ||
172+
(contacts.find_by(phone_number: alternate_phone_variants.map { |variant| "+#{variant}" }) if alternate_phone_variants.any?)
173+
end
174+
175+
# The other ninth-digit forms @phone may be stored under (Brazil/Argentina).
176+
def alternate_phone_variants
177+
@alternate_phone_variants ||= begin
178+
normalizer = Whatsapp::PhoneNumberNormalizationService::NORMALIZERS
179+
.map(&:new)
180+
.find { |candidate| candidate.handles_country?(@phone) }
181+
normalizer ? normalizer.variants(@phone) - [@phone] : []
182+
end
183+
end
184+
163185
# Resolve identifier conflict by transferring the identifier to the phone-based contact.
164186
def transfer_identifier_to(target_contact)
165187
return if target_contact.identifier == @identifier

app/services/whatsapp/phone_normalizers/argentina_phone_normalizer.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ def normalize(waid)
1010
waid.sub(/^549/, '54')
1111
end
1212

13+
# An Argentinian mobile number may appear with or without the "9" after the
14+
# country code, so both forms are variants of the same line.
15+
def variants(waid)
16+
return [waid] unless handles_country?(waid)
17+
18+
[waid, waid.start_with?('549') ? waid.sub(/^549/, '54') : "549#{waid[2..]}"].uniq
19+
end
20+
1321
private
1422

1523
def country_code_pattern

app/services/whatsapp/phone_normalizers/base_phone_normalizer.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ def normalize(waid)
1111
raise NotImplementedError, 'Subclasses must implement #normalize'
1212
end
1313

14+
# All forms the number may appear as on WhatsApp (including itself).
15+
# Country normalizers override this when numbers have known variants.
16+
def variants(waid)
17+
[waid]
18+
end
19+
1420
private
1521

1622
def country_code_pattern

app/services/whatsapp/phone_normalizers/brazil_phone_normalizer.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,20 @@ def normalize(waid)
1818
normalized_number
1919
end
2020

21+
# A Brazilian mobile number may be registered on WhatsApp with or without the
22+
# ninth digit, so both forms are variants of the same line.
23+
def variants(waid)
24+
return [waid] unless handles_country?(waid)
25+
26+
ddd = waid[COUNTRY_CODE_LENGTH, DDD_LENGTH]
27+
number = waid[(COUNTRY_CODE_LENGTH + DDD_LENGTH)..]
28+
29+
candidates = [waid]
30+
candidates << "55#{ddd}9#{number}" if number.length == 8
31+
candidates << "55#{ddd}#{number[1..]}" if number.length == 9 && number.start_with?('9')
32+
candidates
33+
end
34+
2135
private
2236

2337
def country_code_pattern

spec/services/whatsapp/contact_inbox_consolidation_service_spec.rb

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,5 +272,93 @@
272272
end
273273
end
274274
end
275+
276+
context 'when the stored phone carries the Brazilian ninth digit and the webhook delivers the canonical number without it' do
277+
let(:phone) { '551112345678' }
278+
let(:stored_phone) { '5511912345678' }
279+
280+
context 'when a phone-based contact_inbox exists under the other variant' do
281+
let!(:contact) { create(:contact, account: inbox.account, phone_number: "+#{stored_phone}") }
282+
let!(:phone_contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: stored_phone) }
283+
284+
it 'migrates the contact_inbox to lid and aligns the phone to the canonical number' do
285+
service = described_class.new(inbox: inbox, phone: phone, lid: lid, identifier: identifier)
286+
287+
expect { service.perform }.not_to change(ContactInbox, :count)
288+
289+
expect(phone_contact_inbox.reload.source_id).to eq(lid)
290+
expect(contact.reload.identifier).to eq(identifier)
291+
expect(contact.phone_number).to eq("+#{phone}")
292+
end
293+
294+
it 'prefers an exact source_id match over a variant' do
295+
exact_contact = create(:contact, account: inbox.account, phone_number: "+#{phone}")
296+
exact_contact_inbox = create(:contact_inbox, inbox: inbox, contact: exact_contact, source_id: phone)
297+
298+
described_class.new(inbox: inbox, phone: phone, lid: lid, identifier: identifier).perform
299+
300+
expect(exact_contact_inbox.reload.source_id).to eq(lid)
301+
expect(phone_contact_inbox.reload.source_id).to eq(stored_phone)
302+
end
303+
304+
it 'merges a duplicate lid contact created by a previous reply back into the variant phone contact' do
305+
lid_contact = create(:contact, account: inbox.account, phone_number: "+#{phone}", identifier: identifier, name: lid)
306+
lid_contact_inbox = create(:contact_inbox, inbox: inbox, contact: lid_contact, source_id: lid)
307+
lid_conversation = create(:conversation, inbox: inbox, contact: lid_contact, contact_inbox: lid_contact_inbox)
308+
309+
described_class.new(inbox: inbox, phone: phone, lid: lid, identifier: identifier).perform
310+
311+
expect(ContactInbox.exists?(lid_contact_inbox.id)).to be(false)
312+
expect(Contact.exists?(lid_contact.id)).to be(false)
313+
expect(phone_contact_inbox.reload.source_id).to eq(lid)
314+
expect(contact.reload.identifier).to eq(identifier)
315+
expect(contact.phone_number).to eq("+#{phone}")
316+
expect(lid_conversation.reload.contact_id).to eq(contact.id)
317+
expect(lid_conversation.contact_inbox_id).to eq(phone_contact_inbox.id)
318+
end
319+
end
320+
321+
context 'when the contact exists under the other variant without a phone-based contact_inbox' do
322+
let!(:contact) { create(:contact, account: inbox.account, phone_number: "+#{stored_phone}") }
323+
let!(:old_contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: '999999999') }
324+
325+
it 'updates the existing contact_inbox source_id to lid' do
326+
described_class.new(inbox: inbox, phone: phone, lid: lid, identifier: identifier).perform
327+
328+
expect(old_contact_inbox.reload.source_id).to eq(lid)
329+
expect(contact.reload.identifier).to eq(identifier)
330+
end
331+
end
332+
end
333+
334+
context 'when the stored phone misses the Brazilian ninth digit and the webhook delivers it' do
335+
let(:phone) { '5511912345678' }
336+
let(:stored_phone) { '551112345678' }
337+
338+
let!(:contact) { create(:contact, account: inbox.account, phone_number: "+#{stored_phone}") }
339+
let!(:phone_contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: stored_phone) }
340+
341+
it 'migrates the contact_inbox to lid and aligns the phone to the canonical number' do
342+
described_class.new(inbox: inbox, phone: phone, lid: lid, identifier: identifier).perform
343+
344+
expect(phone_contact_inbox.reload.source_id).to eq(lid)
345+
expect(contact.reload.phone_number).to eq("+#{phone}")
346+
end
347+
end
348+
349+
context 'when the stored phone uses a different Argentinian "9" variant' do
350+
let(:phone) { '541112345678' }
351+
let(:stored_phone) { '5491112345678' }
352+
353+
let!(:contact) { create(:contact, account: inbox.account, phone_number: "+#{stored_phone}") }
354+
let!(:phone_contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: stored_phone) }
355+
356+
it 'migrates the contact_inbox to lid and aligns the phone to the canonical number' do
357+
described_class.new(inbox: inbox, phone: phone, lid: lid, identifier: identifier).perform
358+
359+
expect(phone_contact_inbox.reload.source_id).to eq(lid)
360+
expect(contact.reload.phone_number).to eq("+#{phone}")
361+
end
362+
end
275363
end
276364
end

spec/services/whatsapp/incoming_message_baileys_service_spec.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -998,6 +998,25 @@
998998
expect(contact.phone_number).to eq('+5511912345678')
999999
end
10001000

1001+
it 'reuses a contact saved with the Brazilian ninth digit when the reply arrives without it' do
1002+
# Regression: the agent saved the number with the ninth digit and outbound
1003+
# normalization was skipped; the reply delivers the canonical number without
1004+
# it and must not spawn a duplicate contact in a new conversation.
1005+
raw_message[:key][:remoteJidAlt] = '551112345678@s.whatsapp.net'
1006+
contact = create(:contact, account: inbox.account, phone_number: '+5511912345678', identifier: nil)
1007+
contact_inbox = create(:contact_inbox, inbox: inbox, contact: contact, source_id: '5511912345678')
1008+
conversation = create(:conversation, inbox: inbox, contact: contact, contact_inbox: contact_inbox)
1009+
1010+
expect do
1011+
described_class.new(inbox: inbox, params: params).perform
1012+
end.not_to change(Contact, :count)
1013+
1014+
expect(contact_inbox.reload.source_id).to eq('12345678')
1015+
expect(contact.reload.identifier).to eq('12345678@lid')
1016+
expect(contact.phone_number).to eq('+551112345678')
1017+
expect(conversation.reload.messages.last.content).to eq('Hello from Baileys')
1018+
end
1019+
10011020
it 'does not update contact_inbox if source_id is already LID' do
10021021
contact = create(:contact, account: inbox.account, phone_number: '+5511912345678', identifier: '12345678@lid')
10031022
contact_inbox = create(:contact_inbox, inbox: inbox, contact: contact, source_id: '12345678')

0 commit comments

Comments
 (0)