From 1101a02b6ce2e05bdbd80e32574634247bcc0f50 Mon Sep 17 00:00:00 2001 From: CayoPOliveira Date: Thu, 8 May 2025 08:59:37 -0300 Subject: [PATCH 01/10] feat: implement send_read_messages method for WhatsApp channel --- app/models/channel/whatsapp.rb | 6 ++++++ .../providers/whatsapp_baileys_service.rb | 20 +++++++++++++++++++ .../whatsapp_baileys_service_spec.rb | 14 ++++++++++++- 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb index c75da35ffa0e6..615961d3e4afa 100644 --- a/app/models/channel/whatsapp.rb +++ b/app/models/channel/whatsapp.rb @@ -79,6 +79,12 @@ def provider_connection_data data end + def send_read_messages(messages) + return unless provider_service.respond_to?(:send_read_messages) + + provider_service.send_read_messages(phone_number, messages) + end + def toggle_typing_status(typing_status, conversation:) return unless provider_service.respond_to?(:toggle_typing_status) diff --git a/app/services/whatsapp/providers/whatsapp_baileys_service.rb b/app/services/whatsapp/providers/whatsapp_baileys_service.rb index 30f1975bafa88..f731e3376bc1c 100644 --- a/app/services/whatsapp/providers/whatsapp_baileys_service.rb +++ b/app/services/whatsapp/providers/whatsapp_baileys_service.rb @@ -53,6 +53,26 @@ def sync_templates; end def media_url(media_id); end + def send_read_messages(phone_number, messages) + @phone_number = phone_number + + response = HTTParty.post( + "#{provider_url}/connections/#{whatsapp_channel.phone_number}/read-messages", + headers: api_headers, + body: { + keys: messages.map do |message| + { + id: message.source_id, + remoteJid: remote_jid, + fromMe: message.message_type == 'outgoing' + } + end + }.to_json + ) + + process_response(response) + end + def api_headers { 'x-api-key' => api_key, 'Content-Type' => 'application/json' } end diff --git a/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb b/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb index ff880d2097c56..fad65528f7f0b 100644 --- a/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb +++ b/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb @@ -4,7 +4,7 @@ subject(:service) { described_class.new(whatsapp_channel: whatsapp_channel) } let(:whatsapp_channel) { create(:channel_whatsapp, provider: 'baileys', validate_provider_config: false) } - let(:message) { create(:message) } + let(:message) { create(:message, source_id: 'msg_123') } let(:test_send_phone_number) { '551187654321' } let(:test_send_jid) { '551187654321@s.whatsapp.net' } @@ -363,6 +363,18 @@ end end + describe '#send_read_messages' do + it 'when called send read status' do + stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/read-messages") + .with( + headers: stub_headers(whatsapp_channel), + body: { keys: [{ id: message.source_id, remoteJid: test_send_jid, fromMe: false }] }.to_json + ).to_return(status: 200, body: '', headers: {}) + + expect(service.send_read_messages(test_send_phone_number, [message])).to be true + end + end + describe '#toggle_typing_status' do let(:request_path) { "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/presence" } From 1d4775408bc5e9fd64a7399f3e17312940cd8b0e Mon Sep 17 00:00:00 2001 From: CayoPOliveira Date: Thu, 8 May 2025 09:00:05 -0300 Subject: [PATCH 02/10] feat: implement messages_read event handling and dispatch for conversations --- .../api/v1/accounts/conversations_controller.rb | 2 ++ app/listeners/channel_listener.rb | 13 +++++++++++++ lib/events/types.rb | 1 + 3 files changed, 16 insertions(+) diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 8753918fc2d47..a62f9a788ae5b 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -140,6 +140,8 @@ def update_last_seen_on_conversation(last_seen_at, update_assignee) @conversation.update_column(:agent_last_seen_at, last_seen_at) @conversation.update_column(:assignee_last_seen_at, last_seen_at) if update_assignee.present? # rubocop:enable Rails/SkipsModelValidations + + Rails.configuration.dispatcher.dispatch(Events::Types::MESSAGES_READ, Time.zone.now, conversation: @conversation) end def set_conversation_status diff --git a/app/listeners/channel_listener.rb b/app/listeners/channel_listener.rb index e156e0708d1ce..807634842deb0 100644 --- a/app/listeners/channel_listener.rb +++ b/app/listeners/channel_listener.rb @@ -22,6 +22,19 @@ def account_presence_updated(event) end end + def messages_read(event) + conversation = event.data.values_at(:conversation).first + + channel = conversation&.inbox&.channel + return unless channel.respond_to?(:send_read_messages) + + messages = conversation.messages.where(message_type: :incoming).map do |message| + message if message.status != 'read' + end + + channel.send_read_messages(event.name, messages: messages) if messages.present? + end + private def handle_typing_event(event) diff --git a/lib/events/types.rb b/lib/events/types.rb index 935c0f20636a3..7cb214948d76d 100644 --- a/lib/events/types.rb +++ b/lib/events/types.rb @@ -37,6 +37,7 @@ module Events::Types FIRST_REPLY_CREATED = 'first.reply.created' REPLY_CREATED = 'reply.created' MESSAGE_UPDATED = 'message.updated' + MESSAGES_READ = 'messages.read' # contact events CONTACT_CREATED = 'contact.created' From 6416b0a279f7d8dc2137836f3ea7df7395952876 Mon Sep 17 00:00:00 2001 From: gabrieljablonski Date: Thu, 8 May 2025 10:03:42 -0300 Subject: [PATCH 03/10] feat: enhance messages_read handling to include last_seen_at and conversation context --- .../api/v1/accounts/conversations_controller.rb | 5 +++-- app/listeners/channel_listener.rb | 12 ++++++------ app/models/channel/whatsapp.rb | 4 ++-- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index a62f9a788ae5b..68aa975cdd011 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -110,6 +110,9 @@ def toggle_typing_status end def update_last_seen + Rails.configuration.dispatcher.dispatch(Events::Types::MESSAGES_READ, Time.zone.now, conversation: @conversation, + last_seen_at: @conversation.agent_last_seen_at) + update_last_seen_on_conversation(DateTime.now.utc, assignee?) end @@ -140,8 +143,6 @@ def update_last_seen_on_conversation(last_seen_at, update_assignee) @conversation.update_column(:agent_last_seen_at, last_seen_at) @conversation.update_column(:assignee_last_seen_at, last_seen_at) if update_assignee.present? # rubocop:enable Rails/SkipsModelValidations - - Rails.configuration.dispatcher.dispatch(Events::Types::MESSAGES_READ, Time.zone.now, conversation: @conversation) end def set_conversation_status diff --git a/app/listeners/channel_listener.rb b/app/listeners/channel_listener.rb index 807634842deb0..b3461fb8bc3a4 100644 --- a/app/listeners/channel_listener.rb +++ b/app/listeners/channel_listener.rb @@ -23,16 +23,16 @@ def account_presence_updated(event) end def messages_read(event) - conversation = event.data.values_at(:conversation).first + conversation, last_seen_at = event.data.values_at(:conversation, :last_seen_at) - channel = conversation&.inbox&.channel + channel = conversation.inbox.channel return unless channel.respond_to?(:send_read_messages) - messages = conversation.messages.where(message_type: :incoming).map do |message| - message if message.status != 'read' - end + messages = conversation.messages.where(message_type: :incoming) + .where('updated_at > ?', last_seen_at) + .where.not(status: :read) - channel.send_read_messages(event.name, messages: messages) if messages.present? + channel.send_read_messages(messages, conversation: conversation) if messages.any? end private diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb index 615961d3e4afa..414950bcb6f34 100644 --- a/app/models/channel/whatsapp.rb +++ b/app/models/channel/whatsapp.rb @@ -79,10 +79,10 @@ def provider_connection_data data end - def send_read_messages(messages) + def send_read_messages(messages, conversation:) return unless provider_service.respond_to?(:send_read_messages) - provider_service.send_read_messages(phone_number, messages) + provider_service.send_read_messages(conversation.contact.phone_number, messages) end def toggle_typing_status(typing_status, conversation:) From 9a0aabc4639e49c268f69f69a34edd999cdfb28b Mon Sep 17 00:00:00 2001 From: gabrieljablonski Date: Thu, 8 May 2025 10:07:44 -0300 Subject: [PATCH 04/10] feat: update last_seen handling to reference agent_last_seen_at for messages read event --- app/controllers/api/v1/accounts/conversations_controller.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 68aa975cdd011..9a185bf37fd88 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -110,6 +110,7 @@ def toggle_typing_status end def update_last_seen + # NOTE: Use old `agent_last_seen_at`, so we reference messages received after that Rails.configuration.dispatcher.dispatch(Events::Types::MESSAGES_READ, Time.zone.now, conversation: @conversation, last_seen_at: @conversation.agent_last_seen_at) From a39a79142d46e8d861ad37b2ecc2e7462b61b7be Mon Sep 17 00:00:00 2001 From: gabrieljablonski Date: Thu, 8 May 2025 10:11:14 -0300 Subject: [PATCH 05/10] chore: fix rebase --- app/models/channel/whatsapp.rb | 12 +++--- .../providers/whatsapp_baileys_service.rb | 40 +++++++++---------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb index 414950bcb6f34..cc02b149b47cc 100644 --- a/app/models/channel/whatsapp.rb +++ b/app/models/channel/whatsapp.rb @@ -79,12 +79,6 @@ def provider_connection_data data end - def send_read_messages(messages, conversation:) - return unless provider_service.respond_to?(:send_read_messages) - - provider_service.send_read_messages(conversation.contact.phone_number, messages) - end - def toggle_typing_status(typing_status, conversation:) return unless provider_service.respond_to?(:toggle_typing_status) @@ -97,6 +91,12 @@ def update_presence(status) provider_service.update_presence(status) end + def send_read_messages(messages, conversation:) + return unless provider_service.respond_to?(:send_read_messages) + + provider_service.send_read_messages(conversation.contact.phone_number, messages) + end + delegate :setup_channel_provider, to: :provider_service delegate :disconnect_channel_provider, to: :provider_service delegate :send_message, to: :provider_service diff --git a/app/services/whatsapp/providers/whatsapp_baileys_service.rb b/app/services/whatsapp/providers/whatsapp_baileys_service.rb index f731e3376bc1c..a56c67ec8949e 100644 --- a/app/services/whatsapp/providers/whatsapp_baileys_service.rb +++ b/app/services/whatsapp/providers/whatsapp_baileys_service.rb @@ -53,26 +53,6 @@ def sync_templates; end def media_url(media_id); end - def send_read_messages(phone_number, messages) - @phone_number = phone_number - - response = HTTParty.post( - "#{provider_url}/connections/#{whatsapp_channel.phone_number}/read-messages", - headers: api_headers, - body: { - keys: messages.map do |message| - { - id: message.source_id, - remoteJid: remote_jid, - fromMe: message.message_type == 'outgoing' - } - end - }.to_json - ) - - process_response(response) - end - def api_headers { 'x-api-key' => api_key, 'Content-Type' => 'application/json' } end @@ -124,6 +104,26 @@ def update_presence(status) process_response(response) end + def send_read_messages(phone_number, messages) + @phone_number = phone_number + + response = HTTParty.post( + "#{provider_url}/connections/#{whatsapp_channel.phone_number}/read-messages", + headers: api_headers, + body: { + keys: messages.map do |message| + { + id: message.source_id, + remoteJid: remote_jid, + fromMe: message.message_type == 'outgoing' + } + end + }.to_json + ) + + process_response(response) + end + private def provider_url From 79c81a652881c983958c6e96da58eefa1a0dd1b2 Mon Sep 17 00:00:00 2001 From: gabrieljablonski Date: Thu, 8 May 2025 10:16:47 -0300 Subject: [PATCH 06/10] feat: update error handling --- .../whatsapp/providers/whatsapp_baileys_service.rb | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/services/whatsapp/providers/whatsapp_baileys_service.rb b/app/services/whatsapp/providers/whatsapp_baileys_service.rb index a56c67ec8949e..e1bec82c3ceac 100644 --- a/app/services/whatsapp/providers/whatsapp_baileys_service.rb +++ b/app/services/whatsapp/providers/whatsapp_baileys_service.rb @@ -1,4 +1,4 @@ -class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseService +class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseService # rubocop:disable Metrics/ClassLength class MessageContentTypeNotSupported < StandardError; end class MessageNotSentError < StandardError; end @@ -210,5 +210,10 @@ def handle_channel_error whatsapp_channel.update_provider_connection!(connection: 'close') end - with_error_handling :setup_channel_provider, :disconnect_channel_provider, :send_message + with_error_handling :setup_channel_provider, + :disconnect_channel_provider, + :send_message, + :toggle_typing_status, + :update_presence, + :send_read_messages end From 382498533912b58b713941556e8da0a8da7bfac9 Mon Sep 17 00:00:00 2001 From: gabrieljablonski Date: Thu, 8 May 2025 10:18:56 -0300 Subject: [PATCH 07/10] feat: update send_read_messages to mark received messages as not sent by the user --- app/services/whatsapp/providers/whatsapp_baileys_service.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/services/whatsapp/providers/whatsapp_baileys_service.rb b/app/services/whatsapp/providers/whatsapp_baileys_service.rb index e1bec82c3ceac..e3c33b72f117b 100644 --- a/app/services/whatsapp/providers/whatsapp_baileys_service.rb +++ b/app/services/whatsapp/providers/whatsapp_baileys_service.rb @@ -115,7 +115,8 @@ def send_read_messages(phone_number, messages) { id: message.source_id, remoteJid: remote_jid, - fromMe: message.message_type == 'outgoing' + # NOTE: It only makes sense to mark received messages as read + fromMe: false } end }.to_json From fea5bacb2ea25d59025bbccd6614513175533936 Mon Sep 17 00:00:00 2001 From: gabrieljablonski Date: Thu, 8 May 2025 10:42:31 -0300 Subject: [PATCH 08/10] test: controller spec --- .../v1/accounts/conversations_controller_spec.rb | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb index d93886fc321ed..21ad4d4aa5908 100644 --- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb @@ -687,6 +687,20 @@ expect(response).to have_http_status(:success) expect(conversation.reload.assignee_last_seen_at).not_to be_nil end + + it 'dispatches messages.read event' do + freeze_time + conversation.update!(agent_last_seen_at: 1.hour.ago) + allow(Rails.configuration.dispatcher).to receive(:dispatch) + .with(Events::Types::MESSAGES_READ, Time.zone.now, conversation: conversation, last_seen_at: conversation.agent_last_seen_at) + + post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(Rails.configuration.dispatcher).to have_received(:dispatch) + end end end From 8a55a53df47a3810e2f387ed72bf8d6974f5cbd0 Mon Sep 17 00:00:00 2001 From: gabrieljablonski Date: Thu, 8 May 2025 10:48:39 -0300 Subject: [PATCH 09/10] test: channel listener --- spec/listeners/channel_listener_spec.rb | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/spec/listeners/channel_listener_spec.rb b/spec/listeners/channel_listener_spec.rb index c67ab81101ee4..32549c5249420 100644 --- a/spec/listeners/channel_listener_spec.rb +++ b/spec/listeners/channel_listener_spec.rb @@ -91,6 +91,31 @@ end end + describe '#messages_read' do + let(:channel) { create(:channel_whatsapp, sync_templates: false, validate_provider_config: false) } + let(:conversation) { create(:conversation, inbox: create(:inbox, channel: channel)) } + let(:last_seen_at) { 1.day.ago } + + it 'sends read messages to the channel' do + sent_message = create(:message, conversation: conversation, message_type: :incoming, status: :sent) + create(:message, conversation: conversation, message_type: :incoming, status: :read) + allow(channel).to receive(:send_read_messages).with([sent_message], conversation: conversation) + event = Events::Base.new(Events::Types::MESSAGES_READ, Time.zone.now, conversation: conversation, last_seen_at: last_seen_at) + + listener.messages_read(event) + + expect(channel).to have_received(:send_read_messages) + end + + it 'skips the event if the channel does not respond to send_read_messages' do + create(:channel_api, inbox: conversation.inbox) + + expect do + listener.messages_read(Events::Base.new(Events::Types::MESSAGES_READ, Time.zone.now, conversation: conversation, last_seen_at: last_seen_at)) + end.not_to raise_error + end + end + def build_typing_event(event_name, conversation:, is_private: false) Events::Base.new(event_name, Time.zone.now, conversation: conversation, user: create(:user), is_private: is_private) end From 56fded0d3bcadb095d0ec31a488c97126875ccd1 Mon Sep 17 00:00:00 2001 From: gabrieljablonski Date: Thu, 8 May 2025 11:02:18 -0300 Subject: [PATCH 10/10] test: channel and provider --- spec/models/channel/whatsapp_spec.rb | 36 +++++++++++++++++-- .../whatsapp_baileys_service_spec.rb | 20 +++++------ 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/spec/models/channel/whatsapp_spec.rb b/spec/models/channel/whatsapp_spec.rb index e87dc89cd9cb2..67abf2bd3c713 100644 --- a/spec/models/channel/whatsapp_spec.rb +++ b/spec/models/channel/whatsapp_spec.rb @@ -68,6 +68,8 @@ it 'calls provider service method' do provider_double = instance_double(Whatsapp::Providers::WhatsappBaileysService, toggle_typing_status: nil) + allow(provider_double).to receive(:toggle_typing_status) + .with(conversation.contact.phone_number, Events::Types::CONVERSATION_TYPING_ON) allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new) .with(whatsapp_channel: channel) .and_return(provider_double) @@ -75,7 +77,6 @@ channel.toggle_typing_status(Events::Types::CONVERSATION_TYPING_ON, conversation: conversation) expect(provider_double).to have_received(:toggle_typing_status) - .with(conversation.contact.phone_number, Events::Types::CONVERSATION_TYPING_ON) end it 'does not call method if provider service does not implement it' do @@ -96,13 +97,14 @@ it 'calls provider service method' do provider_double = instance_double(Whatsapp::Providers::WhatsappBaileysService, update_presence: nil) + allow(provider_double).to receive(:update_presence).with('online') allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new) .with(whatsapp_channel: channel) .and_return(provider_double) channel.update_presence('online') - expect(provider_double).to have_received(:update_presence).with('online') + expect(provider_double).to have_received(:update_presence) end it 'does not call method if provider service does not implement it' do @@ -118,6 +120,36 @@ end end + describe '#send_read_messages' do + let(:channel) { create(:channel_whatsapp, provider: 'baileys', validate_provider_config: false, sync_templates: false) } + let(:conversation) { create(:conversation) } + let(:message) { create(:message) } + + it 'calls provider service method' do + provider_double = instance_double(Whatsapp::Providers::WhatsappBaileysService, send_read_messages: nil) + allow(provider_double).to receive(:send_read_messages).with([message], conversation.contact.phone_number) + allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new) + .with(whatsapp_channel: channel) + .and_return(provider_double) + + channel.send_read_messages([message], conversation: conversation) + + expect(provider_double).to have_received(:send_read_messages) + end + + it 'does not call method if provider service does not implement it' do + channel = create(:channel_whatsapp, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false) + provider_double = instance_double(Whatsapp::Providers::WhatsappCloudService) + allow(Whatsapp::Providers::WhatsappCloudService).to receive(:new) + .with(whatsapp_channel: channel) + .and_return(provider_double) + + expect do + channel.send_read_messages([message], conversation: conversation) + end.not_to raise_error + end + end + describe 'callbacks' do describe '#disconnect_channel_provider' do context 'when provider is baileys' do diff --git a/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb b/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb index fad65528f7f0b..5a96a86ec40ea 100644 --- a/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb +++ b/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb @@ -29,7 +29,7 @@ response = service.setup_channel_provider - expect(response).to be true + expect(response).to be(true) end end @@ -68,7 +68,7 @@ response = service.disconnect_channel_provider - expect(response).to be true + expect(response).to be(true) end end @@ -312,10 +312,8 @@ end describe '#api_headers' do - context 'when called' do - it 'returns the headers' do - expect(service.api_headers).to eq('x-api-key' => 'test_key', 'Content-Type' => 'application/json') - end + it 'returns the headers' do + expect(service.api_headers).to eq('x-api-key' => 'test_key', 'Content-Type' => 'application/json') end end @@ -326,7 +324,7 @@ .with(headers: stub_headers(whatsapp_channel)) .to_return(status: 200, body: '', headers: {}) - expect(service.validate_provider_config?).to be true + expect(service.validate_provider_config?).to be(true) end end @@ -337,7 +335,7 @@ .to_return(status: 400, body: 'error message', headers: {}) allow(Rails.logger).to receive(:error).with('error message') - expect(service.validate_provider_config?).to be false + expect(service.validate_provider_config?).to be(false) expect(Rails.logger).to have_received(:error) end @@ -364,14 +362,16 @@ end describe '#send_read_messages' do - it 'when called send read status' do + it 'send read messages request' do stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/read-messages") .with( headers: stub_headers(whatsapp_channel), body: { keys: [{ id: message.source_id, remoteJid: test_send_jid, fromMe: false }] }.to_json ).to_return(status: 200, body: '', headers: {}) - expect(service.send_read_messages(test_send_phone_number, [message])).to be true + result = service.send_read_messages(test_send_phone_number, [message]) + + expect(result).to be(true) end end