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:
parent
55c7c435bc
commit
8b663342c2
@ -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?
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user