From 8b663342c291311e5fd70ffdbdc5a9e82bd47c3b Mon Sep 17 00:00:00 2001 From: Gabriel Jablonski Date: Thu, 23 Apr 2026 17:22:54 -0300 Subject: [PATCH] fix(whatsapp): process reactions from WhatsApp Cloud API (#275) Aligns reaction handling with the Baileys/Zapi providers so that reaction webhooks from the official WhatsApp Cloud API create a message flagged as is_reaction and linked to the original wamid, instead of being silently dropped. --- .../whatsapp/incoming_message_base_service.rb | 27 ++++--- .../incoming_message_service_helpers.rb | 10 ++- .../jobs/webhooks/whatsapp_events_job_spec.rb | 22 ++++-- ...ing_message_whatsapp_cloud_service_spec.rb | 76 +++++++++++++++++++ 4 files changed, 118 insertions(+), 17 deletions(-) diff --git a/app/services/whatsapp/incoming_message_base_service.rb b/app/services/whatsapp/incoming_message_base_service.rb index 55b0dede5..81da60788 100644 --- a/app/services/whatsapp/incoming_message_base_service.rb +++ b/app/services/whatsapp/incoming_message_base_service.rb @@ -26,9 +26,10 @@ class Whatsapp::IncomingMessageBaseService # rubocop:disable Metrics/ClassLength def process_messages @lock_acquired = false - # We don't support reactions & ephemeral message now, we need to skip processing the message - # if the webhook event is a reaction or an ephermal message or an unsupported message. - return if unprocessable_message_type?(message_type) + # We don't support ephemeral message now, we need to skip processing the message + # if the webhook event is an ephermal message or an unsupported message. + # Reactions removed by the user arrive with an empty emoji and are skipped to match Baileys behavior. + return if skip_message? # Multiple webhook event can be received against the same message due to misconfigurations in the Meta # business manager account. While we have not found the core reason yet, the following line ensure that @@ -59,6 +60,10 @@ class Whatsapp::IncomingMessageBaseService # rubocop:disable Metrics/ClassLength clear_message_source_id_from_redis if @lock_acquired end + def skip_message? + unprocessable_message_type?(message_type) || reaction_removal? + end + # For regular messages the contact phone is in :from; for echoes it's in :to. def contact_phone_for_lock outgoing_echo ? messages_data.first[:to] : messages_data.first[:from] @@ -168,7 +173,7 @@ class Whatsapp::IncomingMessageBaseService # rubocop:disable Metrics/ClassLength end def attach_files - return if %w[text button interactive location contacts].include?(message_type) + return if %w[text button interactive location contacts reaction].include?(message_type) attachment_payload = messages_data.first[message_type.to_sym] @message.content ||= attachment_payload[:caption] @@ -202,10 +207,6 @@ class Whatsapp::IncomingMessageBaseService # rubocop:disable Metrics/ClassLength end def create_message(message, source_id: nil) - content_attrs = outgoing_echo ? { external_echo: true } : {} - content_attrs[:in_reply_to_external_id] = @in_reply_to_external_id if @in_reply_to_external_id.present? - content_attrs[:external_created_at] = message[:timestamp].to_i - @message = @conversation.messages.build( content: message_content(message), account_id: @inbox.account_id, @@ -215,10 +216,18 @@ class Whatsapp::IncomingMessageBaseService # rubocop:disable Metrics/ClassLength status: outgoing_echo ? :delivered : :sent, sender: outgoing_echo ? nil : @contact, source_id: (source_id || message[:id]).to_s, - content_attributes: content_attrs + content_attributes: build_content_attributes(message) ) end + def build_content_attributes(message) + content_attrs = outgoing_echo ? { external_echo: true } : {} + content_attrs[:in_reply_to_external_id] = @in_reply_to_external_id if @in_reply_to_external_id.present? + content_attrs[:external_created_at] = message[:timestamp].to_i + content_attrs[:is_reaction] = true if message_type == 'reaction' + content_attrs + end + def attach_contact(contact) phones = contact[:phones] phones = [{ phone: 'Phone number is not available' }] if phones.blank? diff --git a/app/services/whatsapp/incoming_message_service_helpers.rb b/app/services/whatsapp/incoming_message_service_helpers.rb index d6f99bf0e..9b8c1f288 100644 --- a/app/services/whatsapp/incoming_message_service_helpers.rb +++ b/app/services/whatsapp/incoming_message_service_helpers.rb @@ -30,7 +30,8 @@ module Whatsapp::IncomingMessageServiceHelpers message.dig(:button, :text) || message.dig(:interactive, :button_reply, :title) || message.dig(:interactive, :list_reply, :title) || - message.dig(:name, :formatted_name) + message.dig(:name, :formatted_name) || + message.dig(:reaction, :emoji) end def file_content_type(file_type) @@ -44,7 +45,11 @@ module Whatsapp::IncomingMessageServiceHelpers end def unprocessable_message_type?(message_type) - %w[reaction ephemeral unsupported request_welcome].include?(message_type) + %w[ephemeral unsupported request_welcome].include?(message_type) + end + + def reaction_removal? + message_type == 'reaction' && messages_data.first.dig(:reaction, :emoji).blank? end def processed_waid(waid) @@ -61,6 +66,7 @@ module Whatsapp::IncomingMessageServiceHelpers def process_in_reply_to(message) @in_reply_to_external_id = message['context']&.[]('id') + @in_reply_to_external_id = message.dig(:reaction, :message_id) if message[:type] == 'reaction' end def find_message_by_source_id(source_id) diff --git a/spec/jobs/webhooks/whatsapp_events_job_spec.rb b/spec/jobs/webhooks/whatsapp_events_job_spec.rb index 2fe314d93..6b68bb27d 100644 --- a/spec/jobs/webhooks/whatsapp_events_job_spec.rb +++ b/spec/jobs/webhooks/whatsapp_events_job_spec.rb @@ -136,7 +136,7 @@ RSpec.describe Webhooks::WhatsappEventsJob do job.perform_now(wb_params) end - it 'Ignore reaction type message and stop raising error' do + it 'creates a reaction message flagged with is_reaction' do other_channel = create(:channel_whatsapp, phone_number: '+1987654', provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false) wb_params = { @@ -147,7 +147,9 @@ RSpec.describe Webhooks::WhatsappEventsJob do value: { contacts: [{ profile: { name: 'Test Test' }, wa_id: '1111981136571' }], messages: [{ - from: '1111981136571', reaction: { emoji: '👍' }, timestamp: '1664799904', type: 'reaction' + from: '1111981136571', id: 'wamid.REACTION_ID', + reaction: { message_id: 'wamid.ORIGINAL_ID', emoji: '👍' }, + timestamp: '1664799904', type: 'reaction' }], metadata: { phone_number_id: other_channel.provider_config['phone_number_id'], @@ -157,12 +159,17 @@ RSpec.describe Webhooks::WhatsappEventsJob do }] }] }.with_indifferent_access + expect do Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: other_channel.inbox, params: wb_params).perform - end.not_to change(Message, :count) + end.to change(Message, :count).by(1) + + reaction_message = Message.find_by(source_id: 'wamid.REACTION_ID') + expect(reaction_message.content).to eq('👍') + expect(reaction_message.content_attributes['is_reaction']).to be true end - it 'ignore reaction type message, would not create contact if the reaction is the first event' do + it 'skips reaction messages when the emoji is blank (reaction removal)' do other_channel = create(:channel_whatsapp, phone_number: '+1987654', provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false) wb_params = { @@ -173,7 +180,9 @@ RSpec.describe Webhooks::WhatsappEventsJob do value: { contacts: [{ profile: { name: 'Test Test' }, wa_id: '1111981136571' }], messages: [{ - from: '1111981136571', reaction: { emoji: '👍' }, timestamp: '1664799904', type: 'reaction' + from: '1111981136571', id: 'wamid.REACTION_REMOVAL_ID', + reaction: { message_id: 'wamid.ORIGINAL_ID', emoji: '' }, + timestamp: '1664799904', type: 'reaction' }], metadata: { phone_number_id: other_channel.provider_config['phone_number_id'], @@ -183,9 +192,10 @@ RSpec.describe Webhooks::WhatsappEventsJob do }] }] }.with_indifferent_access + expect do Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: other_channel.inbox, params: wb_params).perform - end.not_to change(Contact, :count) + end.to not_change(Message, :count).and not_change(Contact, :count) end it 'ignore request_welcome type message, would not create contact or conversation' do diff --git a/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb b/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb index e1d4d25a0..9d6086286 100644 --- a/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb +++ b/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb @@ -258,6 +258,82 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do end end end + + context 'when message is a reaction' do + let(:reaction_params) do + { + phone_number: whatsapp_channel.phone_number, + object: 'whatsapp_business_account', + entry: [{ + changes: [{ + value: { + contacts: [{ profile: { name: 'Gabriel Jablonski' }, wa_id: '553499503261' }], + messages: [{ + from: '553499503261', + id: 'wamid.REACTION_MESSAGE_ID', + timestamp: '1776974260', + type: 'reaction', + reaction: { + message_id: 'wamid.ORIGINAL_MESSAGE_ID', + emoji: '❤️' + } + }] + } + }] + }] + }.with_indifferent_access + end + + context 'when the reacted message exists in Chatwoot' do + it 'creates a reaction message linked to the original message' do + contact = create(:contact, phone_number: '+553499503261', account: whatsapp_channel.account) + contact_inbox = create(:contact_inbox, contact: contact, inbox: whatsapp_channel.inbox, source_id: '553499503261') + conversation = create(:conversation, contact: contact, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox) + original_message = create(:message, + conversation: conversation, + source_id: 'wamid.ORIGINAL_MESSAGE_ID', + content: 'Original message') + + described_class.new(inbox: whatsapp_channel.inbox, params: reaction_params).perform + + reaction_message = whatsapp_channel.inbox.messages.find_by(source_id: 'wamid.REACTION_MESSAGE_ID') + expect(reaction_message).to be_present + expect(reaction_message.content).to eq('❤️') + expect(reaction_message.message_type).to eq('incoming') + expect(reaction_message.attachments).to be_empty + expect(reaction_message.content_attributes['is_reaction']).to be true + expect(reaction_message.content_attributes['in_reply_to']).to eq(original_message.id) + expect(reaction_message.content_attributes['in_reply_to_external_id']).to eq('wamid.ORIGINAL_MESSAGE_ID') + end + end + + context 'when the reacted message does not exist in Chatwoot' do + it 'still creates the reaction message but discards the reply reference' do + described_class.new(inbox: whatsapp_channel.inbox, params: reaction_params).perform + + reaction_message = whatsapp_channel.inbox.messages.find_by(source_id: 'wamid.REACTION_MESSAGE_ID') + expect(reaction_message).to be_present + expect(reaction_message.content).to eq('❤️') + expect(reaction_message.content_attributes['is_reaction']).to be true + expect(reaction_message.content_attributes['in_reply_to']).to be_nil + expect(reaction_message.content_attributes['in_reply_to_external_id']).to be_nil + end + end + + context 'when the reaction emoji is blank (reaction removed)' do + let(:reaction_removal_params) do + reaction_params.deep_dup.tap do |payload| + payload[:entry][0][:changes][0][:value][:messages][0][:reaction][:emoji] = '' + end + end + + it 'does not create a message' do + expect do + described_class.new(inbox: whatsapp_channel.inbox, params: reaction_removal_params).perform + end.not_to(change { whatsapp_channel.inbox.messages.count }) + end + end + end end # Métodos auxiliares para reduzir o tamanho do exemplo