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.
This commit is contained in:
Gabriel Jablonski 2026-04-23 17:22:54 -03:00 committed by GitHub
parent 55c7c435bc
commit 8b663342c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 118 additions and 17 deletions

View File

@ -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?

View File

@ -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)

View File

@ -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

View File

@ -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