feat: baileys messages reactions (#33)

* fix: update send_message method to handle  baileys-api send-message changes

* chore: simplify update of is_unsupported

* feat: add support for message reactions in WhatsApp Baileys service

* chore: update context description for unsupported message in send_message spec

* feat: enhance incoming messages Baileys service to support message reactions and external source ID handling

* fix: correct typo in comment for is_reaction attribute in Message model

* chore: simplify message handling by removing external source ID checks and unused methods

* fix: update comment to reflect necessary change for source_id in set_contact method

* fix: refactor message handling for reactions and simplify jid generation

* fix: add comment to clarify behavior of find_message_by_source_id method

* fix: update unsupported message handling

* chore: specs for reaction messages

* fix: simplify message update handling by removing redundant validation method

* fix: update reaction message handling to use in_reply_to_external_id and fix spec

* fix: enhance message handling for unsupported types and update specs

* fix: update reaction message handling to set is_reaction flag and enhance specs

* fix: update specs for send message in reaction

* revert: remove mb0 from input

* fix: streamline jid generation in send_message_request

* refactor: specs

* refactor: move content attributes to helper

* test: spec for empty reaction

* refactor: streamline references to replied message

* test: refactor spec

* test: refactor spec

---------

Co-authored-by: gabrieljablonski <contact@gabrieljablonski.com>
This commit is contained in:
Cayo P. R. Oliveira 2025-04-29 20:57:36 -03:00 committed by GitHub
parent 8dc893d322
commit 8e913236e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 117 additions and 14 deletions

View File

@ -63,7 +63,6 @@ export default {
<label class="input-container">
<span v-if="label">{{ label }}</span>
<input
class="!mb-0"
:value="modelValue"
:type="type"
:placeholder="placeholder"

View File

@ -101,9 +101,10 @@ class Message < ApplicationRecord
# [:deleted] : Used to denote whether the message was deleted by the agent
# [:external_created_at] : Can specify if the message was created at a different timestamp externally
# [:external_error : Can specify if the message creation failed due to an error at external API
# [:is_reaction] : Used to denote if the message is a reaction and differentiate it from a simple reply message
store :content_attributes, accessors: [:submitted_email, :items, :submitted_values, :email, :in_reply_to, :deleted,
:external_created_at, :story_sender, :story_id, :external_error,
:translations, :in_reply_to_external_id, :is_unsupported], coder: JSON
:translations, :in_reply_to_external_id, :is_unsupported, :is_reaction], coder: JSON
store :external_source_ids, accessors: [:slack], coder: JSON, prefix: :external_source_id

View File

@ -67,6 +67,7 @@ class Whatsapp::IncomingMessageBaileysService < Whatsapp::IncomingMessageBaseSer
def set_contact
push_name = contact_name
contact_inbox = ::ContactInboxWithContactBuilder.new(
# FIXME: update the source_id to complete jid in future
source_id: phone_number_from_jid,
inbox: inbox,
contact_attributes: { name: push_name, phone_number: "+#{phone_number_from_jid}" }
@ -100,6 +101,8 @@ class Whatsapp::IncomingMessageBaileysService < Whatsapp::IncomingMessageBaseSer
case message_type
when 'text'
create_message
when 'reaction'
create_message if message_content.present?
when 'image', 'file', 'video', 'audio', 'sticker'
create_message
attach_media
@ -148,6 +151,7 @@ class Whatsapp::IncomingMessageBaileysService < Whatsapp::IncomingMessageBaseSer
return 'poll' if msg.key?(:pollCreationMessageV3)
return 'event' if msg.key?(:eventMessage)
return 'sticker' if msg.key?(:stickerMessage)
return 'reaction' if msg.key?(:reactionMessage)
'unsupported'
end
@ -165,10 +169,19 @@ class Whatsapp::IncomingMessageBaileysService < Whatsapp::IncomingMessageBaseSer
sender: sender,
sender_type: sender_type,
message_type: message_type,
in_reply_to_external_id: nil
content_attributes: message_content_attributes
)
end
def message_content_attributes
return unless message_type == 'reaction'
{
in_reply_to_external_id: @raw_message.dig(:message, :reactionMessage, :key, :id),
is_reaction: true
}
end
def incoming?
!@raw_message[:key][:fromMe]
end
@ -231,6 +244,8 @@ class Whatsapp::IncomingMessageBaileysService < Whatsapp::IncomingMessageBaseSer
@raw_message.dig(:message, :imageMessage, :caption)
when 'video'
@raw_message.dig(:message, :videoMessage, :caption)
when 'reaction'
@raw_message.dig(:message, :reactionMessage, :text)
end
end
@ -263,17 +278,12 @@ class Whatsapp::IncomingMessageBaileysService < Whatsapp::IncomingMessageBaseSer
end
def handle_update
raise MessageNotFoundError unless valid_update_message?
raise MessageNotFoundError unless find_message_by_source_id(message_id)
update_status if @raw_message.dig(:update, :status).present?
update_message_content if @raw_message.dig(:update, :message).present?
end
def valid_update_message?
@message = find_message_by_source_id(message_id)
@message.present?
end
def update_status
status = status_mapper
@message.update!(status: status) if status.present? && status_transition_allowed?(status)

View File

@ -33,7 +33,9 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer
@message = message
@phone_number = phone_number
if message.attachments.present?
if message.content_attributes[:is_reaction]
@message_content = reaction_message_content
elsif message.attachments.present?
@message_content = attachment_message_content
elsif message.content.present?
@message_content = { text: @message.content }
@ -74,6 +76,15 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer
whatsapp_channel.provider_config['api_key'].presence || DEFAULT_API_KEY
end
def reaction_message_content
{
react: { key: { id: @message.in_reply_to_external_id,
remoteJid: remote_jid,
fromMe: true },
text: @message.content }
}
end
def attachment_message_content
attachment = @message.attachments.first
buffer = Base64.strict_encode64(attachment.file.download)
@ -103,7 +114,7 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer
"#{provider_url}/connections/#{whatsapp_channel.phone_number}/send-message",
headers: api_headers,
body: {
jid: "#{@phone_number.delete('+')}@s.whatsapp.net",
jid: remote_jid,
messageContent: @message_content
}.to_json
)
@ -118,6 +129,10 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer
response.success?
end
def remote_jid
"#{@phone_number.delete('+')}@s.whatsapp.net"
end
private_class_method def self.with_error_handling(*method_names)
method_names.each do |method_name|
original_method = instance_method(method_name)

View File

@ -164,7 +164,7 @@ describe Whatsapp::IncomingMessageBaileysService do
}
end
it 'creates an alert of unsupported message' do
it 'creates an unsupported message' do
described_class.new(inbox: inbox, params: params).perform
conversation = inbox.conversations.last
@ -373,6 +373,51 @@ describe Whatsapp::IncomingMessageBaileysService do
end
end
context 'when message type is reaction' do
let(:phone_number) { '5511912345678' }
let(:jid) { "#{phone_number}@s.whatsapp.net" }
let(:raw_message) do
{
key: { id: 'reaction_123', remoteJid: jid, fromMe: false },
message: { reactionMessage: { key: { remoteJid: jid, fromMe: true, id: 'msg_123' }, text: '👍' } },
pushName: 'John Doe'
}
end
let(:params) do
{
webhookVerifyToken: webhook_verify_token,
event: 'messages.upsert',
data: {
type: 'notify',
messages: [raw_message]
}
}
end
let!(:message) do
contact = create(:contact, account: inbox.account, name: phone_number)
contact_inbox = create(:contact_inbox, inbox: inbox, contact: contact, source_id: phone_number)
conversation = create(:conversation, inbox: inbox, contact_inbox: contact_inbox)
create(:message, inbox: inbox, conversation: conversation, source_id: 'msg_123')
end
it 'creates the reaction' do
described_class.new(inbox: inbox, params: params).perform
reaction = message.conversation.messages.last
expect(reaction.is_reaction).to be(true)
expect(reaction.in_reply_to).to eq(message.id)
expect(reaction.in_reply_to_external_id).to eq(message.source_id)
end
it 'does not create the reaction if content is empty' do
raw_message[:message][:reactionMessage][:text] = ''
described_class.new(inbox: inbox, params: params).perform
expect(message.conversation.messages.count).to eq(1)
end
end
context 'when message type is image' do
let(:raw_message) do
{

View File

@ -6,8 +6,8 @@ describe Whatsapp::Providers::WhatsappBaileysService do
let(:whatsapp_channel) { create(:channel_whatsapp, provider: 'baileys', validate_provider_config: false) }
let(:message) { create(:message) }
let(:test_send_phone_number) { '+5511987654321' }
let(:test_send_jid) { '5511987654321@s.whatsapp.net' }
let(:test_send_phone_number) { '551187654321' }
let(:test_send_jid) { '551187654321@s.whatsapp.net' }
before do
stub_const('Whatsapp::Providers::WhatsappBaileysService::DEFAULT_CLIENT_NAME', 'chatwoot-test')
@ -92,6 +92,39 @@ describe Whatsapp::Providers::WhatsappBaileysService do
end
describe '#send_message' do
context 'when message is a reaction' do
let(:inbox) { whatsapp_channel.inbox }
let(:contact) { create(:contact, account: inbox.account, name: 'John Doe', phone_number: "+#{test_send_phone_number}") }
let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: test_send_phone_number) }
let(:conversation) { create(:conversation, inbox: inbox, contact_inbox: contact_inbox) }
let!(:message) { create(:message, inbox: inbox, conversation: conversation, sender: contact, source_id: 'msg_123') }
let(:reaction) do
create(:message, inbox: inbox, conversation: conversation, sender: contact, content: '👍',
content_attributes: { is_reaction: true, in_reply_to: message.id })
end
it 'sends the reaction message' do
stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/send-message")
.with(
headers: stub_headers(whatsapp_channel),
body: {
jid: test_send_jid,
messageContent: { react: { key: { id: message.source_id,
remoteJid: test_send_jid,
fromMe: true },
text: '👍' } }
}.to_json
)
.to_return(
status: 200,
headers: { 'Content-Type' => 'application/json' },
body: { 'data' => { 'key' => { 'id' => 'reaction_123' } } }.to_json
)
result = service.send_message(test_send_phone_number, reaction)
expect(result).to eq('reaction_123')
context 'when message does not have content nor attachments' do
it 'updates the message with content attribute is_unsupported' do
message.update!(content: nil)