From b5d3250a2a7f2afaedf6b5e16a1d933bd61a28d6 Mon Sep 17 00:00:00 2001 From: Gabriel Jablonski Date: Sat, 24 Jan 2026 23:37:21 -0300 Subject: [PATCH] feat(baileys): add reply context handling (#196) --- .../providers/whatsapp_baileys_service.rb | 43 +++++- .../whatsapp_baileys_service_spec.rb | 140 ++++++++++++++++++ .../providers/whatsapp_zapi_service_spec.rb | 78 ++++++++++ 3 files changed, 259 insertions(+), 2 deletions(-) diff --git a/app/services/whatsapp/providers/whatsapp_baileys_service.rb b/app/services/whatsapp/providers/whatsapp_baileys_service.rb index 0313dbea0..2817b8ade 100644 --- a/app/services/whatsapp/providers/whatsapp_baileys_service.rb +++ b/app/services/whatsapp/providers/whatsapp_baileys_service.rb @@ -67,9 +67,9 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer if @message.content_attributes[:is_reaction] @message_content = reaction_message_content elsif @message.attachments.present? - @message_content = attachment_message_content + @message_content = attachment_message_content.merge(reply_context) elsif @message.outgoing_content.present? - @message_content = { text: @message.outgoing_content } + @message_content = { text: @message.outgoing_content }.merge(reply_context) else @message.update!(is_unsupported: true) return @@ -304,6 +304,45 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer } end + def reply_context + reply_to_external_id = @message.content_attributes[:in_reply_to_external_id] + return {} if reply_to_external_id.blank? + + reply_to_message = @message.conversation.messages.find_by(source_id: reply_to_external_id) + return {} unless reply_to_message + + { + quotedMessage: { + key: { + id: reply_to_external_id, + remoteJid: remote_jid, + fromMe: reply_to_message.message_type == 'outgoing' + }, + message: quoted_message_content(reply_to_message) + } + } + end + + def quoted_message_content(message) + if message.attachments.present? + attachment = message.attachments.first + case attachment.file_type + when 'image' + { imageMessage: { caption: message.content } } + when 'video' + { videoMessage: { caption: message.content } } + when 'audio' + { audioMessage: {} } + when 'file' + { documentMessage: { caption: message.content, fileName: attachment.file.filename.to_s } } + else + { conversation: message.content.to_s } + end + else + { conversation: message.content.to_s } + end + end + def attachment_message_content # rubocop:disable Metrics/MethodLength attachment = @message.attachments.first buffer = attachment_to_base64(attachment) diff --git a/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb b/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb index c13f970e6..1e3cf4758 100644 --- a/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb +++ b/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb @@ -437,6 +437,146 @@ describe Whatsapp::Providers::WhatsappBaileysService do end end + context 'when message is a reply to another message' do + let(:inbox) { whatsapp_channel.inbox } + let(:account_user) { create(:account_user, account: inbox.account) } + let(:contact) { create(:contact, account: inbox.account, name: 'John Doe', phone_number: "+#{test_send_phone_number}") } + let(:conversation) do + contact_inbox = create(:contact_inbox, inbox: inbox, contact: contact, source_id: test_send_phone_number) + create(:conversation, inbox: inbox, contact_inbox: contact_inbox) + end + + it 'sends text reply to outgoing message with quotedMessage' do + original_message = create(:message, inbox: inbox, conversation: conversation, sender: account_user, + message_type: 'outgoing', source_id: 'original_msg_123', content: 'Original text') + reply_message = create(:message, inbox: inbox, conversation: conversation, sender: account_user, + content: 'Reply text', content_attributes: { in_reply_to_external_id: original_message.source_id }) + + stub_request(:post, request_path) + .with( + headers: stub_headers(whatsapp_channel), + body: { + jid: test_send_jid, + messageContent: { + text: 'Reply text', + quotedMessage: { + key: { + id: 'original_msg_123', + remoteJid: test_send_jid, + fromMe: true + }, + message: { conversation: 'Original text' } + } + } + }.to_json + ) + .to_return( + status: 200, + headers: { 'Content-Type' => 'application/json' }, + body: result_body.to_json + ) + + result = service.send_message(test_send_phone_number, reply_message) + + expect(result).to eq('msg_123') + end + + it 'sends text reply to incoming message with quotedMessage' do + original_message = create(:message, inbox: inbox, conversation: conversation, sender: contact, + message_type: 'incoming', source_id: 'incoming_msg_456', content: 'Incoming text') + reply_message = create(:message, inbox: inbox, conversation: conversation, sender: account_user, + content: 'Reply to incoming', content_attributes: { in_reply_to_external_id: original_message.source_id }) + + stub_request(:post, request_path) + .with( + headers: stub_headers(whatsapp_channel), + body: { + jid: test_send_jid, + messageContent: { + text: 'Reply to incoming', + quotedMessage: { + key: { + id: 'incoming_msg_456', + remoteJid: test_send_jid, + fromMe: false + }, + message: { conversation: 'Incoming text' } + } + } + }.to_json + ) + .to_return( + status: 200, + headers: { 'Content-Type' => 'application/json' }, + body: result_body.to_json + ) + + result = service.send_message(test_send_phone_number, reply_message) + + expect(result).to eq('msg_123') + end + + it 'sends reply to message with image attachment' do + original_message = create(:message, inbox: inbox, conversation: conversation, sender: contact, + message_type: 'incoming', source_id: 'image_msg_789', content: 'Check this image') + original_message.attachments.create!(account_id: original_message.account_id, file_type: 'image', + file: { io: StringIO.new('fake'), filename: 'image.png' }) + + reply_message = create(:message, inbox: inbox, conversation: conversation, sender: account_user, + content: 'Nice image!', content_attributes: { in_reply_to_external_id: original_message.source_id }) + + stub_request(:post, request_path) + .with( + headers: stub_headers(whatsapp_channel), + body: { + jid: test_send_jid, + messageContent: { + text: 'Nice image!', + quotedMessage: { + key: { + id: 'image_msg_789', + remoteJid: test_send_jid, + fromMe: false + }, + message: { imageMessage: { caption: 'Check this image' } } + } + } + }.to_json + ) + .to_return( + status: 200, + headers: { 'Content-Type' => 'application/json' }, + body: result_body.to_json + ) + + result = service.send_message(test_send_phone_number, reply_message) + + expect(result).to eq('msg_123') + end + + it 'sends message without quotedMessage when in_reply_to_external_id is blank' do + regular_message = create(:message, inbox: inbox, conversation: conversation, sender: account_user, content: 'Regular message') + + stub_request(:post, request_path) + .with( + headers: stub_headers(whatsapp_channel), + body: { + jid: test_send_jid, + messageContent: { text: 'Regular message' } + }.to_json + ) + .to_return( + status: 200, + headers: { 'Content-Type' => 'application/json' }, + body: result_body.to_json + ) + + result = service.send_message(test_send_phone_number, regular_message) + + expect(result).to eq('msg_123') + end + end + context 'when request is unsuccessful' do it 'raises ProviderUnavailableError' do stub_request(:post, request_path) diff --git a/spec/services/whatsapp/providers/whatsapp_zapi_service_spec.rb b/spec/services/whatsapp/providers/whatsapp_zapi_service_spec.rb index de887d678..6122c8514 100644 --- a/spec/services/whatsapp/providers/whatsapp_zapi_service_spec.rb +++ b/spec/services/whatsapp/providers/whatsapp_zapi_service_spec.rb @@ -441,6 +441,84 @@ describe Whatsapp::Providers::WhatsappZapiService do end end + context 'when message is a reply to another message' do + let(:inbox) { whatsapp_channel.inbox } + let(:account_user) { create(:account_user, account: inbox.account) } + let(:contact) { create(:contact, account: inbox.account, name: 'John Doe', phone_number: "+#{test_send_phone_number}") } + let(:conversation) do + contact_inbox = create(:contact_inbox, inbox: inbox, contact: contact, source_id: test_send_phone_number) + create(:conversation, inbox: inbox, contact_inbox: contact_inbox) + end + + it 'sends text reply with messageId parameter' do + original_message = create(:message, inbox: inbox, conversation: conversation, sender: contact, + message_type: 'incoming', source_id: 'original_zapi_msg_123', content: 'Original text') + reply_message = create(:message, inbox: inbox, conversation: conversation, sender: account_user, + content: 'Reply text', content_attributes: { in_reply_to_external_id: original_message.source_id }) + + stub_request(:post, request_path) + .with( + headers: stub_headers, + body: { + phone: test_send_phone_number, + message: 'Reply text', + messageId: 'original_zapi_msg_123' + }.to_json + ) + .to_return(status: 200, body: result_body.to_json, headers: { 'Content-Type' => 'application/json' }) + + result = service.send_message("+#{test_send_phone_number}", reply_message) + + expect(result).to eq('msg_123') + end + + it 'sends image reply with messageId parameter' do + base64_image = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=' + buffer = "data:image/png;base64,#{base64_image}" + + original_message = create(:message, inbox: inbox, conversation: conversation, sender: contact, + message_type: 'incoming', source_id: 'original_zapi_img_456', content: 'Check this') + reply_message = create(:message, inbox: inbox, conversation: conversation, sender: account_user, + content: 'Nice!', content_attributes: { in_reply_to_external_id: original_message.source_id }) + reply_message.attachments.create!(account_id: reply_message.account_id, file_type: 'image', + file: { io: StringIO.new(Base64.decode64(base64_image)), filename: 'reply.png' }) + + stub_request(:post, "#{api_instance_path_with_token}/send-image") + .with( + headers: stub_headers, + body: { + phone: test_send_phone_number, + image: buffer, + caption: 'Nice!', + messageId: 'original_zapi_img_456' + }.to_json + ) + .to_return(status: 200, body: result_body.to_json, headers: { 'Content-Type' => 'application/json' }) + + result = service.send_message("+#{test_send_phone_number}", reply_message) + + expect(result).to eq('msg_123') + end + + it 'sends message without messageId when in_reply_to_external_id is blank' do + regular_message = create(:message, inbox: inbox, conversation: conversation, sender: account_user, content: 'Regular message') + + stub_request(:post, request_path) + .with( + headers: stub_headers, + body: { + phone: test_send_phone_number, + message: 'Regular message' + }.to_json + ) + .to_return(status: 200, body: result_body.to_json, headers: { 'Content-Type' => 'application/json' }) + + result = service.send_message("+#{test_send_phone_number}", regular_message) + + expect(result).to eq('msg_123') + end + end + context 'when message is an image file' do let(:base64_image) { 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=' } let(:buffer) { "data:image/png;base64,#{base64_image}" }