Compare commits

...
Author SHA1 Message Date
Muhsin e496e0712d fix: specs 2025-09-25 20:32:07 +05:30
Muhsin 654c951998 fix: inconsistencies for twilio whatsapp mobile numbers 2025-09-25 19:20:14 +05:30
7 changed files with 400 additions and 13 deletions
@@ -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
@@ -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)
@@ -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
@@ -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
+4
View File
@@ -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
@@ -402,6 +402,230 @@ describe Twilio::IncomingMessageService do
existing_contact.reload
expect(existing_contact.name).to eq('Alice Johnson')
end
describe 'When the incoming number is a Brazilian number in new format with 9 included' 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
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
let!(:whatsapp_twilio_channel) do
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
inbox: create(:inbox, account: account, greeting_enabled: false))
end
it 'appends to existing contact when contact inbox exists in old format' 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
it 'appends to existing contact when contact inbox exists in new format' 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
it 'creates contact inbox with incoming number when no existing contact' 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
describe 'When the incoming number is an Argentine number with 9 after country code' 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
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
let!(:whatsapp_twilio_channel) do
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
inbox: create(:inbox, account: account, greeting_enabled: false))
end
it 'appends to existing contact when contact inbox exists with same format' 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
it 'creates contact inbox with incoming number when no existing contact' 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
@@ -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