From e65e18e9c5dfb60ebd707ec5dc1353ba67dac436 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 22 Jul 2026 10:33:40 +0400 Subject: [PATCH] fix: normalize phone numbers during whatsapp channel lookup (#13709) Fixes https://linear.app/chatwoot/issue/PLA-99/whatsapp-messages-dropped-for-brazilargentina-numbers-due-to-phone Fixes https://github.com/chatwoot/chatwoot/issues/14492 Meta's WhatsApp Cloud API includes `display_phone_number` in webhook payloads, but its format can differ from the number stored in Chatwoot's channel record. In Brazil, Meta omits the mobile 9 prefix. For example, it sends 55419XXXXXXX (12 digits) instead of 554199XXXXXXX (13 digits). In Argentina, Meta adds an extra 9 after the country code. For example, it sends 549XXXXXXXXXX instead of 54XXXXXXXXXX. The whatsapp event job uses `display_phone_number` for an exact-match channel lookup. When the formats do not match, the lookup returns nil and the incoming message is silently dropped, logging: `Inactive WhatsApp channel: unknown - .` The fix extends `get_channel_from_wb_payload` to fall back to normalized phone number matching using the existing PhoneNumberNormalizationService normalizers (Brazil, Argentina), which were previously only used for contact-level lookups. --------- Co-authored-by: Sojan Jose Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> --- .../webhooks/whatsapp_controller.rb | 16 +--- app/jobs/webhooks/whatsapp_events_job.rb | 10 +-- .../webhook_channel_finder_service.rb | 35 ++++++++ .../jobs/webhooks/whatsapp_events_job_spec.rb | 88 +++++++++++++++++++ 4 files changed, 132 insertions(+), 17 deletions(-) create mode 100644 app/services/whatsapp/webhook_channel_finder_service.rb diff --git a/app/controllers/webhooks/whatsapp_controller.rb b/app/controllers/webhooks/whatsapp_controller.rb index ee71f3c92..8fd18678c 100644 --- a/app/controllers/webhooks/whatsapp_controller.rb +++ b/app/controllers/webhooks/whatsapp_controller.rb @@ -47,18 +47,10 @@ class Webhooks::WhatsappController < ActionController::API metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata) return if metadata.blank? - phone_number = normalized_phone_number(metadata[:display_phone_number]) - phone_number_id = metadata[:phone_number_id] - channel = Channel::Whatsapp.find_by(phone_number: phone_number) - - return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id - end - - def normalized_phone_number(phone_number) - return if phone_number.blank? - - phone_number = phone_number.to_s - phone_number.start_with?('+') ? phone_number : "+#{phone_number}" + Whatsapp::WebhookChannelFinderService.new( + display_phone_number: metadata[:display_phone_number], + phone_number_id: metadata[:phone_number_id] + ).perform end def inactive_whatsapp_number? diff --git a/app/jobs/webhooks/whatsapp_events_job.rb b/app/jobs/webhooks/whatsapp_events_job.rb index 14429e61c..f81ce242b 100644 --- a/app/jobs/webhooks/whatsapp_events_job.rb +++ b/app/jobs/webhooks/whatsapp_events_job.rb @@ -153,11 +153,11 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob end def get_channel_from_wb_payload(wb_params) - phone_number = "+#{wb_params[:entry].first[:changes].first.dig(:value, :metadata, :display_phone_number)}" - phone_number_id = wb_params[:entry].first[:changes].first.dig(:value, :metadata, :phone_number_id) - channel = Channel::Whatsapp.find_by(phone_number: phone_number) - # validate to ensure the phone number id matches the whatsapp channel - return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id + metadata = wb_params[:entry].first[:changes].first.dig(:value, :metadata) || {} + Whatsapp::WebhookChannelFinderService.new( + display_phone_number: metadata[:display_phone_number], + phone_number_id: metadata[:phone_number_id] + ).perform end end diff --git a/app/services/whatsapp/webhook_channel_finder_service.rb b/app/services/whatsapp/webhook_channel_finder_service.rb new file mode 100644 index 000000000..32fe8356f --- /dev/null +++ b/app/services/whatsapp/webhook_channel_finder_service.rb @@ -0,0 +1,35 @@ +# Resolves the WhatsApp channel for an inbound WhatsApp Cloud webhook. Meta's +# display_phone_number can arrive formatted or in a country-specific variant (e.g. Brazil +# omits the mobile 9, Argentina adds a digit after the country code), so we try the +# raw digits first and then a normalized fallback, accepting only a candidate whose +# phone_number_id matches. +class Whatsapp::WebhookChannelFinderService + def initialize(display_phone_number:, phone_number_id:) + @display_phone_number = display_phone_number + @phone_number_id = phone_number_id + end + + def perform + return if digits.blank? + + candidates = [ + Channel::Whatsapp.find_by(phone_number: "+#{digits}"), + channel_by_normalized_number + ] + candidates.compact.find { |channel| channel.provider_config['phone_number_id'] == @phone_number_id } + end + + private + + def digits + @digits ||= @display_phone_number.to_s.gsub(/[^0-9]/, '') + end + + def channel_by_normalized_number + normalizer = Whatsapp::PhoneNumberNormalizationService::NORMALIZERS + .lazy.map(&:new).find { |n| n.handles_country?(digits) } + return unless normalizer + + Channel::Whatsapp.find_by(phone_number: "+#{normalizer.normalize(digits)}") + end +end diff --git a/spec/jobs/webhooks/whatsapp_events_job_spec.rb b/spec/jobs/webhooks/whatsapp_events_job_spec.rb index 8d1b24b52..8310839da 100644 --- a/spec/jobs/webhooks/whatsapp_events_job_spec.rb +++ b/spec/jobs/webhooks/whatsapp_events_job_spec.rb @@ -345,6 +345,94 @@ RSpec.describe Webhooks::WhatsappEventsJob do end.not_to change(Conversation, :count) end + it 'finds channel using normalized Brazil phone number when display_phone_number is missing the 9 digit' do + brazil_channel = create(:channel_whatsapp, phone_number: '+5541999887766', provider: 'whatsapp_cloud', + sync_templates: false, validate_provider_config: false) + wb_params = { + object: 'whatsapp_business_account', + entry: [{ + changes: [{ + value: { + metadata: { + phone_number_id: brazil_channel.provider_config['phone_number_id'], + display_phone_number: '554199887766' + } + } + }] + }] + } + allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service) + expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: brazil_channel.inbox, params: wb_params) + job.perform_now(wb_params) + end + + it 'finds channel using normalized Argentina phone number when display_phone_number has extra 9 digit' do + argentina_channel = create(:channel_whatsapp, phone_number: '+541112345678', provider: 'whatsapp_cloud', + sync_templates: false, validate_provider_config: false) + wb_params = { + object: 'whatsapp_business_account', + entry: [{ + changes: [{ + value: { + metadata: { + phone_number_id: argentina_channel.provider_config['phone_number_id'], + display_phone_number: '5491112345678' + } + } + }] + }] + } + allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service) + expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: argentina_channel.inbox, params: wb_params) + job.perform_now(wb_params) + end + + it 'finds channel when display_phone_number contains formatting characters' do + formatted_channel = create(:channel_whatsapp, phone_number: '+14155552671', provider: 'whatsapp_cloud', + sync_templates: false, validate_provider_config: false) + wb_params = { + object: 'whatsapp_business_account', + entry: [{ + changes: [{ + value: { + metadata: { + phone_number_id: formatted_channel.provider_config['phone_number_id'], + display_phone_number: '+1 415-555-2671' + } + } + }] + }] + } + allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service) + expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: formatted_channel.inbox, params: wb_params) + job.perform_now(wb_params) + end + + it 'prefers the phone_number_id match when a raw display_phone_number collision exists' do + normalized_channel = create(:channel_whatsapp, phone_number: '+5541999887766', provider: 'whatsapp_cloud', + sync_templates: false, validate_provider_config: false) + create(:channel_whatsapp, phone_number: '+554199887766', provider: 'whatsapp_cloud', + sync_templates: false, validate_provider_config: false).tap do |raw_channel| + raw_channel.update!(provider_config: raw_channel.provider_config.merge('phone_number_id' => 'other-id')) + end + wb_params = { + object: 'whatsapp_business_account', + entry: [{ + changes: [{ + value: { + metadata: { + phone_number_id: normalized_channel.provider_config['phone_number_id'], + display_phone_number: '554199887766' + } + } + }] + }] + } + allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service) + expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: normalized_channel.inbox, params: wb_params) + job.perform_now(wb_params) + end + it 'will not enque Whatsapp::IncomingMessageWhatsappCloudService when invalid phone number id' do other_channel = create(:channel_whatsapp, phone_number: '+1987654', provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false)