Compare commits

...

17 Commits

Author SHA1 Message Date
Tanmay Deep Sharma
3223cb68f4 Merge branch 'develop' into fix/whatsapp-call-placeholder-contact-name 2026-09-03 11:47:08 +05:30
Tanmay Deep Sharma
b1a5870083 fix(whatsapp): apply the caller profile name to a reused call contact 2026-09-03 09:28:20 +05:30
Tanmay Deep Sharma
826d79395f refactor(whatsapp): compact contact attribute construction to satisfy module length 2026-09-02 17:07:25 +05:30
Tanmay Deep Sharma
db1e62492f Merge branch 'develop' into fix/whatsapp-coexistence-brazil-duplicate-contacts 2026-09-02 16:57:48 +05:30
Muhsin Keloth
478b5f8e81 Merge branch 'develop' into fix/whatsapp-coexistence-brazil-duplicate-contacts 2026-08-31 12:32:02 +04:00
Muhsin
38c4d0ff66 fix(whatsapp): update twilio profile names for phone candidates 2026-08-31 12:25:04 +04:00
Muhsin Keloth
18792f9051 Merge branch 'develop' into fix/whatsapp-coexistence-brazil-duplicate-contacts 2026-08-31 12:23:34 +04:00
Muhsin Keloth
9b84b520f0 Merge branch 'develop' into fix/whatsapp-coexistence-brazil-duplicate-contacts 2026-08-31 11:16:53 +04:00
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 Keloth
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 Keloth
7e0f1cf29e Merge branch 'develop' into fix/whatsapp-coexistence-brazil-duplicate-contacts 2026-07-22 12:59:10 +04:00
Muhsin Keloth
513d9de7fb Merge branch 'develop' into fix/whatsapp-coexistence-brazil-duplicate-contacts 2026-06-22 12:57:08 +04:00
Muhsin Keloth
22dea1959f Merge branch 'develop' into fix/whatsapp-coexistence-brazil-duplicate-contacts 2026-06-11 17:14:33 +04:00
Muhsin
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
6 changed files with 102 additions and 43 deletions

View File

@@ -0,0 +1,21 @@
# A contact created from a phone-only payload is named after its own number. Replace that
# placeholder the first time a provider tells us who the person actually is, and leave a name
# a human would recognise alone.
class Contacts::PlaceholderNameUpdater
pattr_initialize [:contact!, :name!, :phone_numbers!]
def perform
return if name.blank? || contact.name == name
return unless placeholder_name?
contact.update!(name: name)
end
private
def placeholder_name?
Array(phone_numbers).compact_blank.uniq.any? do |number|
contact.name == number || contact.name == TelephoneNumber.parse(number).international_number
end
end
end

View File

@@ -188,28 +188,12 @@ class Twilio::IncomingMessageService
end
def update_contact_name_if_needed
return if params[:ProfileName].blank?
return if @contact.name == params[:ProfileName]
return if phone_number.blank?
# Only update if current name exactly matches a phone number candidate
return unless contact_name_matches_phone_number?
@contact.update!(name: params[:ProfileName])
end
def contact_name_matches_phone_number?
return false if phone_number.blank?
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
Contacts::PlaceholderNameUpdater.new(
contact: @contact,
name: params[:ProfileName],
phone_numbers: [phone_number, *twilio_whatsapp_phone_number_candidates]
).perform
end
end

View File

@@ -223,24 +223,14 @@ class Whatsapp::IncomingMessageBaseService
end
def update_contact_with_profile_name(contact_params)
profile_name = contact_params.dig(:profile, :name)
return if profile_name.blank?
return if @contact.name == profile_name
message_phone_number = whatsapp_phone_number(messages_data.first[:from])
return if message_phone_number.blank?
# 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?
return false if (message_phone_number = whatsapp_phone_number(messages_data.first[:from])).blank?
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
Contacts::PlaceholderNameUpdater.new(
contact: @contact,
name: contact_params.dig(:profile, :name),
phone_numbers: phone_number_candidates(message_phone_number).map { |number| "+#{number}" }
).perform
end
end

View File

@@ -15,7 +15,10 @@ class Whatsapp::InboundCallIdentityBuilder
]
source_ids = Whatsapp::IdentitySourceIdOrderer.new(inbox: inbox, phone_source_id: phone_source_id(phone), source_ids: identifiers).perform
{ source_ids: source_ids, contact_attributes: contact_attributes(contact, phone, source_ids.first) }
# `profile_name` stays raw: the display name below falls back to the formatted phone, which
# would rewrite one phone placeholder into another whenever Meta sends no name.
{ source_ids: source_ids, contact_attributes: contact_attributes(contact, phone, source_ids.first),
profile_name: contact.dig(:profile, :name).presence }
end
private

View File

@@ -99,7 +99,7 @@ class Whatsapp::IncomingCallService
extra_meta = { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers }
call = Voice::InboundCallBuilder.perform!(inbox: inbox, call_sid: payload[:id],
provider: :whatsapp, extra_meta: extra_meta, caller: identity)
sync_caller_identifiers(call, identity)
sync_caller_identity(call, identity)
tombstone = consume_terminate_tombstone(payload[:id])
finalize_terminate(call, tombstone['duration'], tombstone['terminate_reason']) if tombstone
call
@@ -107,9 +107,15 @@ class Whatsapp::IncomingCallService
end
# Backfill every caller alias (the builder only stores the first) so a later event keyed on any one lands on this thread.
def sync_caller_identifiers(call, identity)
def sync_caller_identity(call, identity)
attributes = identity[:contact_attributes]
Whatsapp::IdentifierSyncService.new(contact_inbox: call.conversation.contact_inbox, contact: call.contact)
.perform(source_ids: identity[:source_ids], phone_number: identity.dig(:contact_attributes, :phone_number))
.perform(source_ids: identity[:source_ids], phone_number: attributes[:phone_number])
# A reused contact keeps its old name, so one created phone-only stays named after its number.
Contacts::PlaceholderNameUpdater.new(
contact: call.contact, name: identity[:profile_name],
phone_numbers: [attributes[:phone_number], *attributes[:phone_number_candidates]]
).perform
end
# `connect` is the WebRTC tunnel-ready signal, not the pickup signal. Apply

View File

@@ -462,4 +462,59 @@ describe Whatsapp::IncomingCallService do
expect(Call.where(provider_call_id: %w[wacid_a wacid_b]).pluck(:provider_call_id)).to contain_exactly('wacid_a', 'wacid_b')
end
end
describe 'caller profile name on an existing contact' do
let(:offer) { { session: { sdp_type: 'offer', sdp: 'v=0' } } }
def ring(from, name: 'John Doe', extra: {})
params = { contacts: [{ profile: { name: name }, wa_id: from }.merge(extra)],
calls: [{ id: 'wacid_name', from: from, event: 'connect', **offer }] }.with_indifferent_access
described_class.new(inbox: inbox, params: params).perform
end
it 'replaces a phone-number placeholder name on the contact the call reused' do
contact = create(:contact, name: "+#{from_number}", phone_number: "+#{from_number}", account: account)
create(:contact_inbox, contact: contact, inbox: inbox, source_id: from_number)
ring(from_number)
expect(contact.reload.name).to eq('John Doe')
end
it 'replaces an internationally formatted placeholder name' do
contact = create(:contact, name: TelephoneNumber.parse("+#{from_number}").international_number,
phone_number: "+#{from_number}", account: account)
create(:contact_inbox, contact: contact, inbox: inbox, source_id: from_number)
ring(from_number)
expect(contact.reload.name).to eq('John Doe')
end
it 'replaces a placeholder matching a normalized phone candidate' do
contact = create(:contact, name: '+5541988887777', phone_number: '+5541988887777', account: account)
ring('554188887777', name: 'Maria Silva')
expect(contact.reload.name).to eq('Maria Silva')
end
it 'leaves a real name untouched' do
contact = create(:contact, name: 'Existing Human', phone_number: "+#{from_number}", account: account)
create(:contact_inbox, contact: contact, inbox: inbox, source_id: from_number)
ring(from_number, name: 'Impostor')
expect(contact.reload.name).to eq('Existing Human')
end
it 'leaves the placeholder alone when Meta sends no profile name' do
contact = create(:contact, name: "+#{from_number}", phone_number: "+#{from_number}", account: account)
create(:contact_inbox, contact: contact, inbox: inbox, source_id: from_number)
ring(from_number, name: nil)
expect(contact.reload.name).to eq("+#{from_number}")
end
end
end