diff --git a/app/services/twilio/incoming_message_service.rb b/app/services/twilio/incoming_message_service.rb index 7a74488e2..026a2fc49 100644 --- a/app/services/twilio/incoming_message_service.rb +++ b/app/services/twilio/incoming_message_service.rb @@ -44,6 +44,13 @@ class Twilio::IncomingMessageService twilio_channel.sms? ? params[:From] : params[:From].gsub('whatsapp:', '') end + def normalized_phone_number + return phone_number unless twilio_channel.whatsapp? + + # Use the generic normalization service for Twilio WhatsApp + Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider("whatsapp:#{phone_number}", :twilio) + end + def formatted_phone_number TelephoneNumber.parse(phone_number).international_number end @@ -53,8 +60,10 @@ class Twilio::IncomingMessageService end def set_contact + source_id = twilio_channel.whatsapp? ? normalized_phone_number : params[:From] + contact_inbox = ::ContactInboxWithContactBuilder.new( - source_id: params[:From], + source_id: source_id, inbox: inbox, contact_attributes: contact_attributes ).perform diff --git a/app/services/whatsapp/incoming_message_service_helpers.rb b/app/services/whatsapp/incoming_message_service_helpers.rb index e40dc408f..46ad255aa 100644 --- a/app/services/whatsapp/incoming_message_service_helpers.rb +++ b/app/services/whatsapp/incoming_message_service_helpers.rb @@ -48,7 +48,7 @@ module Whatsapp::IncomingMessageServiceHelpers end def processed_waid(waid) - Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact(waid) + Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider(waid, :cloud) end def error_webhook_event?(message) diff --git a/app/services/whatsapp/phone_normalizers/argentina_phone_normalizer.rb b/app/services/whatsapp/phone_normalizers/argentina_phone_normalizer.rb new file mode 100644 index 000000000..d854ba6f7 --- /dev/null +++ b/app/services/whatsapp/phone_normalizers/argentina_phone_normalizer.rb @@ -0,0 +1,13 @@ +class Whatsapp::PhoneNormalizers::ArgentinaPhoneNormalizer < Whatsapp::PhoneNormalizers::BasePhoneNormalizer + def handles_country?(waid) + waid.start_with?('549') + end + + def normalize(waid) + return waid unless handles_country?(waid) + + # Remove the '9' after country code for Argentina numbers + # 5491123456789 -> 541123456789 + waid.sub(/^549/, '54') + end +end diff --git a/app/services/whatsapp/phone_number_normalization_service.rb b/app/services/whatsapp/phone_number_normalization_service.rb index b8e416794..76421e899 100644 --- a/app/services/whatsapp/phone_number_normalization_service.rb +++ b/app/services/whatsapp/phone_number_normalization_service.rb @@ -1,23 +1,40 @@ # Service to handle phone number normalization for WhatsApp messages -# Currently supports Brazil phone number format variations +# Currently supports Brazil and Argentina phone number format variations +# Supports both WhatsApp Cloud API and Twilio WhatsApp providers # Designed to be extensible for additional countries in future PRs # -# Usage: Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact(waid) +# Usage: +# Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider(number, :cloud) +# Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider(number, :twilio) class Whatsapp::PhoneNumberNormalizationService def initialize(inbox) @inbox = inbox end - # Main entry point for phone number normalization - # Returns the source_id of an existing contact if found, otherwise returns original waid - def normalize_and_find_contact(waid) - normalizer = find_normalizer_for_country(waid) - return waid unless normalizer + # Generic method to handle both WhatsApp Cloud and Twilio formats + # Extracts clean number, normalizes it, and returns appropriate format for provider + # + # @param raw_number [String] The phone number in provider-specific format + # - Cloud: "919745786257" (clean number) + # - Twilio: "whatsapp:+919745786257" (prefixed format) + # @param provider [Symbol] :cloud or :twilio + # @return [String] Normalized source_id in provider format or original if not found + def normalize_and_find_contact_by_provider(raw_number, provider) + # Extract clean number based on provider format + clean_number = extract_clean_number(raw_number, provider) - normalized_waid = normalizer.normalize(waid) - existing_contact_inbox = find_existing_contact_inbox(normalized_waid) + # Find appropriate normalizer for the country + normalizer = find_normalizer_for_country(clean_number) + return raw_number unless normalizer - existing_contact_inbox&.source_id || waid + # Normalize the clean number + normalized_clean_number = normalizer.normalize(clean_number) + + # Format for provider and check for existing contact + provider_format = format_for_provider(normalized_clean_number, provider) + existing_contact_inbox = find_existing_contact_inbox(provider_format) + + existing_contact_inbox&.source_id || raw_number end private @@ -33,7 +50,32 @@ class Whatsapp::PhoneNumberNormalizationService inbox.contact_inboxes.find_by(source_id: normalized_waid) end + # Extract clean number from provider-specific format + def extract_clean_number(raw_number, provider) + case provider + when :cloud + raw_number # Already clean: "919745786257" + when :twilio + raw_number.gsub(/^whatsapp:\+/, '') # Remove prefix: "whatsapp:+919745786257" → "919745786257" + else + raise ArgumentError, "Unsupported provider: #{provider}. Use :cloud or :twilio" + end + end + + # Format normalized number for provider-specific storage + def format_for_provider(clean_number, provider) + case provider + when :cloud + clean_number # Keep clean: "919745786257" + when :twilio + "whatsapp:+#{clean_number}" # Add prefix: "919745786257" → "whatsapp:+919745786257" + else + raise ArgumentError, "Unsupported provider: #{provider}. Use :cloud or :twilio" + end + end + NORMALIZERS = [ - Whatsapp::PhoneNormalizers::BrazilPhoneNormalizer + Whatsapp::PhoneNormalizers::BrazilPhoneNormalizer, + Whatsapp::PhoneNormalizers::ArgentinaPhoneNormalizer ].freeze end diff --git a/spec/factories/channel/twilio_sms.rb b/spec/factories/channel/twilio_sms.rb index 94f632efd..1963a4f28 100644 --- a/spec/factories/channel/twilio_sms.rb +++ b/spec/factories/channel/twilio_sms.rb @@ -13,5 +13,9 @@ FactoryBot.define do sequence(:phone_number) { |n| "+123456789#{n}1" } messaging_service_sid { nil } end + + trait :whatsapp do + medium { :whatsapp } + end end end diff --git a/spec/services/twilio/incoming_message_service_spec.rb b/spec/services/twilio/incoming_message_service_spec.rb index d32ef59bb..9b9f33445 100644 --- a/spec/services/twilio/incoming_message_service_spec.rb +++ b/spec/services/twilio/incoming_message_service_spec.rb @@ -402,6 +402,234 @@ describe Twilio::IncomingMessageService do existing_contact.reload expect(existing_contact.name).to eq('Alice Johnson') end + + context 'when handling Brazilian WhatsApp numbers' do + let!(:whatsapp_twilio_channel) do + create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx', + inbox: create(:inbox, account: account, greeting_enabled: false)) + end + + describe 'When the incoming number is a Brazilian number in new format with 9 included' do + it 'creates appropriate conversations, message and contacts if contact does not exist' do + params = { + SmsSid: 'SMxx', + From: 'whatsapp:+5541988887777', + AccountSid: 'ACxxx', + MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid, + Body: 'Test message from Brazil', + ProfileName: 'João Silva' + } + + described_class.new(params: params).perform + + expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0) + expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('João Silva') + expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Brazil') + expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+5541988887777') + end + + it 'appends to existing contact if contact inbox exists' do + # Create existing contact with same format + normalized_contact = create(:contact, account: account, phone_number: '+5541988887777') + contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+5541988887777', contact: normalized_contact, + inbox: whatsapp_twilio_channel.inbox) + last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox) + + params = { + SmsSid: 'SMxx', + From: 'whatsapp:+5541988887777', + AccountSid: 'ACxxx', + MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid, + Body: 'Another message from Brazil', + ProfileName: 'João Silva' + } + + described_class.new(params: params).perform + + # No new conversation should be created + expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1) + # Message appended to the last conversation + expect(last_conversation.messages.last.content).to eq('Another message from Brazil') + end + end + + describe 'When incoming number is a Brazilian number in old format without the 9 included' do + context 'when a contact inbox exists in the old format without 9 included' do + it 'appends to existing contact' do + # Create existing contact with old format (12 digits) + old_contact = create(:contact, account: account, phone_number: '+554188887777') + contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+554188887777', contact: old_contact, inbox: whatsapp_twilio_channel.inbox) + last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox) + + params = { + SmsSid: 'SMxx', + From: 'whatsapp:+554188887777', + AccountSid: 'ACxxx', + MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid, + Body: 'Test message from Brazil old format', + ProfileName: 'Maria Silva' + } + + described_class.new(params: params).perform + + # No new conversation should be created + expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1) + # Message appended to the last conversation + expect(last_conversation.messages.last.content).to eq('Test message from Brazil old format') + end + end + + context 'when a contact inbox exists in the new format with 9 included' do + it 'appends to existing contact' do + # Create existing contact with new format (13 digits) + normalized_contact = create(:contact, account: account, phone_number: '+5541988887777') + contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+5541988887777', contact: normalized_contact, + inbox: whatsapp_twilio_channel.inbox) + last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox) + + # Incoming message with old format (12 digits) + params = { + SmsSid: 'SMxx', + From: 'whatsapp:+554188887777', + AccountSid: 'ACxxx', + MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid, + Body: 'Test message from Brazil', + ProfileName: 'João Silva' + } + + described_class.new(params: params).perform + + # Should find and use existing contact, not create duplicate + expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1) + # Message appended to the existing conversation + expect(last_conversation.messages.last.content).to eq('Test message from Brazil') + # Should use the existing contact's source_id (normalized format) + expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+5541988887777') + end + end + + context 'when a contact inbox does not exist in the new format with 9 included' do + it 'creates contact inbox with the incoming number' do + params = { + SmsSid: 'SMxx', + From: 'whatsapp:+554188887777', + AccountSid: 'ACxxx', + MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid, + Body: 'Test message from Brazil', + ProfileName: 'Carlos Silva' + } + + described_class.new(params: params).perform + + expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0) + expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('Carlos Silva') + expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Brazil') + expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+554188887777') + end + end + end + end + + context 'when handling Argentine WhatsApp numbers' do + let!(:whatsapp_twilio_channel) do + create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx', + inbox: create(:inbox, account: account, greeting_enabled: false)) + end + + describe 'When the incoming number is an Argentine number with 9 after country code' do + it 'creates appropriate conversations, message and contacts if contact does not exist' do + params = { + SmsSid: 'SMxx', + From: 'whatsapp:+5491123456789', + AccountSid: 'ACxxx', + MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid, + Body: 'Test message from Argentina', + ProfileName: 'Carlos Mendoza' + } + + described_class.new(params: params).perform + + expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0) + expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('Carlos Mendoza') + expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Argentina') + expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+5491123456789') + end + + it 'appends to existing contact if contact inbox exists with normalized format' do + # Create existing contact with normalized format (without 9 after country code) + normalized_contact = create(:contact, account: account, phone_number: '+541123456789') + contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+541123456789', contact: normalized_contact, + inbox: whatsapp_twilio_channel.inbox) + last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox) + + # Incoming message with 9 after country code + params = { + SmsSid: 'SMxx', + From: 'whatsapp:+5491123456789', + AccountSid: 'ACxxx', + MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid, + Body: 'Test message from Argentina', + ProfileName: 'Carlos Mendoza' + } + + described_class.new(params: params).perform + + # Should find and use existing contact, not create duplicate + expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1) + # Message appended to the existing conversation + expect(last_conversation.messages.last.content).to eq('Test message from Argentina') + # Should use the normalized source_id from existing contact + expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+541123456789') + end + end + + describe 'When incoming number is an Argentine number without 9 after country code' do + context 'when a contact inbox exists with the same format' do + it 'appends to existing contact' do + # Create existing contact with same format (without 9) + contact = create(:contact, account: account, phone_number: '+541123456789') + contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+541123456789', contact: contact, inbox: whatsapp_twilio_channel.inbox) + last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox) + + params = { + SmsSid: 'SMxx', + From: 'whatsapp:+541123456789', + AccountSid: 'ACxxx', + MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid, + Body: 'Test message from Argentina', + ProfileName: 'Ana García' + } + + described_class.new(params: params).perform + + # No new conversation should be created + expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1) + # Message appended to the last conversation + expect(last_conversation.messages.last.content).to eq('Test message from Argentina') + end + end + + context 'when a contact inbox does not exist' do + it 'creates contact inbox with the incoming number' do + params = { + SmsSid: 'SMxx', + From: 'whatsapp:+541123456789', + AccountSid: 'ACxxx', + MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid, + Body: 'Test message from Argentina', + ProfileName: 'Diego López' + } + + described_class.new(params: params).perform + + expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0) + expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('Diego López') + expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Argentina') + expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+541123456789') + end + end + end + end end end end diff --git a/spec/services/whatsapp/phone_number_normalization_service_spec.rb b/spec/services/whatsapp/phone_number_normalization_service_spec.rb new file mode 100644 index 000000000..bd89c8916 --- /dev/null +++ b/spec/services/whatsapp/phone_number_normalization_service_spec.rb @@ -0,0 +1,95 @@ +require 'rails_helper' + +RSpec.describe Whatsapp::PhoneNumberNormalizationService do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account) } + let(:service) { described_class.new(inbox) } + + describe '#normalize_and_find_contact_by_provider' do + context 'when handling Brazilian numbers' do + context 'with WhatsApp Cloud provider' do + it 'normalizes old format and finds existing contact with new format' do + # Create existing contact with new format (13 digits) + create(:contact_inbox, inbox: inbox, source_id: '5541988887777') + + # Incoming old format (12 digits) + result = service.normalize_and_find_contact_by_provider('554188887777', :cloud) + + expect(result).to eq('5541988887777') + end + + it 'returns original if no existing contact found' do + result = service.normalize_and_find_contact_by_provider('554188887777', :cloud) + + expect(result).to eq('554188887777') + end + end + + context 'with Twilio provider' do + it 'normalizes old format and finds existing contact with new format' do + # Create existing contact with new format (Twilio format with 13 digits) + create(:contact_inbox, inbox: inbox, source_id: 'whatsapp:+5541988887777') + + # Incoming old format (Twilio format with 12 digits) + result = service.normalize_and_find_contact_by_provider('whatsapp:+554188887777', :twilio) + + expect(result).to eq('whatsapp:+5541988887777') + end + + it 'returns original if no existing contact found' do + result = service.normalize_and_find_contact_by_provider('whatsapp:+554188887777', :twilio) + + expect(result).to eq('whatsapp:+554188887777') + end + end + end + + context 'when handling Argentine numbers' do + context 'with WhatsApp Cloud provider' do + it 'normalizes number with 9 and finds existing contact without 9' do + # Create existing contact with normalized format (without 9) + create(:contact_inbox, inbox: inbox, source_id: '541123456789') + + # Incoming format with 9 after country code + result = service.normalize_and_find_contact_by_provider('5491123456789', :cloud) + + expect(result).to eq('541123456789') + end + end + + context 'with Twilio provider' do + it 'normalizes number with 9 and finds existing contact without 9' do + # Create existing contact with normalized format (Twilio format without 9) + create(:contact_inbox, inbox: inbox, source_id: 'whatsapp:+541123456789') + + # Incoming format with 9 after country code (Twilio format) + result = service.normalize_and_find_contact_by_provider('whatsapp:+5491123456789', :twilio) + + expect(result).to eq('whatsapp:+541123456789') + end + end + end + + context 'when handling unsupported countries' do + it 'returns original number for WhatsApp Cloud' do + result = service.normalize_and_find_contact_by_provider('919876543210', :cloud) + + expect(result).to eq('919876543210') + end + + it 'returns original number for Twilio' do + result = service.normalize_and_find_contact_by_provider('whatsapp:+919876543210', :twilio) + + expect(result).to eq('whatsapp:+919876543210') + end + end + + context 'when handling invalid provider' do + it 'raises ArgumentError for unsupported provider' do + expect do + service.normalize_and_find_contact_by_provider('919876543210', :unknown) + end.to raise_error(ArgumentError, 'Unsupported provider: unknown. Use :cloud or :twilio') + end + end + end +end