From 21133c3383b585c6a83cfa5bdef265bd8078593b Mon Sep 17 00:00:00 2001 From: "Cayo P. R. Oliveira" Date: Thu, 22 May 2025 11:52:03 -0300 Subject: [PATCH] feat: mark unread conversations (#49) * feat: add MESSAGES_UNREAD event and implement unread action in ConversationsController * feat: rename MESSAGES_UNREAD event to CONVERSATION_UNREAD and update unread action in ConversationsController * feat: implement send_unread_conversation method and update conversation_unread event handling * feat: add test for dispatching conversation.unread event in ConversationsController * feat: add conversation_unread event handling in ChannelListener * feat: add send_unread_conversation method and corresponding tests in Whatsapp channel * feat: add logging for nil last_message in send_unread_conversation method * feat: add send_unread_conversation to error handling methods * feat: implement send_unread_conversation method with logging for nil last_message * fix: add nil check and logging for last_message in send_unread_conversation method * fix: update conversation_unread method to call unread_conversation instead of send_unread_conversation * refactor: rename send_unread_conversation to unread_conversation and update method implementation * refactor: rename send_unread_conversation to send_unread_messages and update implementation to handle multiple messages * refactor: rename send_read_messages to read_messages and send_unread_messages to unread_message for clarity * refactor: rename send_read_messages to read_messages and send_unread_messages to unread_message for clarity * refactor: rename send_read_messages to read_messages for consistency --- .../v1/accounts/conversations_controller.rb | 2 + app/listeners/channel_listener.rb | 12 ++++- app/models/channel/whatsapp.rb | 13 +++-- .../providers/whatsapp_baileys_service.rb | 30 ++++++++++- lib/events/types.rb | 1 + .../accounts/conversations_controller_spec.rb | 13 +++++ spec/listeners/channel_listener_spec.rb | 30 ++++++++--- spec/models/channel/whatsapp_spec.rb | 51 ++++++++++++++++--- .../whatsapp_baileys_service_spec.rb | 29 +++++++++-- 9 files changed, 157 insertions(+), 24 deletions(-) diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 9a185bf37..77f82e507 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -118,6 +118,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro end def unread + Rails.configuration.dispatcher.dispatch(Events::Types::CONVERSATION_UNREAD, Time.zone.now, conversation: @conversation) + last_incoming_message = @conversation.messages.incoming.last last_seen_at = last_incoming_message.created_at - 1.second if last_incoming_message.present? update_last_seen_on_conversation(last_seen_at, true) diff --git a/app/listeners/channel_listener.rb b/app/listeners/channel_listener.rb index 438dae941..f70fd0288 100644 --- a/app/listeners/channel_listener.rb +++ b/app/listeners/channel_listener.rb @@ -11,6 +11,14 @@ class ChannelListener < BaseListener handle_typing_event(event) end + def conversation_unread(event) + conversation = event.data[:conversation] + channel = conversation.inbox.channel + return unless channel.respond_to?(:unread_conversation) + + channel.unread_conversation(conversation) + end + def account_presence_updated(event) account_id, user_id, status = event.data.values_at(:account_id, :user_id, :status) account = Account.find(account_id) @@ -26,13 +34,13 @@ class ChannelListener < BaseListener conversation, last_seen_at = event.data.values_at(:conversation, :last_seen_at) channel = conversation.inbox.channel - return unless channel.respond_to?(:send_read_messages) + return unless channel.respond_to?(:read_messages) messages = conversation.messages.where(message_type: :incoming).where.not(status: :read) messages = messages.where('updated_at > ?', last_seen_at) if last_seen_at.present? - channel.send_read_messages(messages, conversation: conversation) if messages.any? + channel.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 cc02b149b..111afb45b 100644 --- a/app/models/channel/whatsapp.rb +++ b/app/models/channel/whatsapp.rb @@ -91,10 +91,17 @@ class Channel::Whatsapp < ApplicationRecord provider_service.update_presence(status) end - def send_read_messages(messages, conversation:) - return unless provider_service.respond_to?(:send_read_messages) + def read_messages(messages, conversation:) + return unless provider_service.respond_to?(:read_messages) - provider_service.send_read_messages(conversation.contact.phone_number, messages) + provider_service.read_messages(conversation.contact.phone_number, messages) + end + + def unread_conversation(conversation) + return unless provider_service.respond_to?(:unread_message) + + last_message = conversation.messages.last + provider_service.unread_message(conversation.contact.phone_number, last_message) if last_message end delegate :setup_channel_provider, to: :provider_service diff --git a/app/services/whatsapp/providers/whatsapp_baileys_service.rb b/app/services/whatsapp/providers/whatsapp_baileys_service.rb index 19164ad6e..e3c38db4e 100644 --- a/app/services/whatsapp/providers/whatsapp_baileys_service.rb +++ b/app/services/whatsapp/providers/whatsapp_baileys_service.rb @@ -108,7 +108,7 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer process_response(response) end - def send_read_messages(phone_number, messages) + def read_messages(phone_number, messages) @phone_number = phone_number response = HTTParty.post( @@ -129,6 +129,31 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer process_response(response) end + def unread_message(phone_number, message) # rubocop:disable Metrics/MethodLength + @phone_number = phone_number + + response = HTTParty.post( + "#{provider_url}/connections/#{whatsapp_channel.phone_number}/chat-modify", + headers: api_headers, + body: { + jid: remote_jid, + mod: { + markRead: false, + lastMessages: { + key: { + id: message.source_id, + remoteJid: remote_jid, + fromMe: message.message_type == 'outgoing' + }, + messageTimestamp: message.content_attributes[:external_created_at] + } + } + }.to_json + ) + + process_response(response) + end + private def provider_url @@ -220,5 +245,6 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer :send_message, :toggle_typing_status, :update_presence, - :send_read_messages + :read_messages, + :unread_message end diff --git a/lib/events/types.rb b/lib/events/types.rb index 7cb214948..5e1e6abc4 100644 --- a/lib/events/types.rb +++ b/lib/events/types.rb @@ -31,6 +31,7 @@ module Events::Types CONVERSATION_RECORDING = 'conversation.recording' CONVERSATION_TYPING_OFF = 'conversation.typing_off' CONVERSATION_MENTIONED = 'conversation.mentioned' + CONVERSATION_UNREAD = 'conversation.unread' # message events MESSAGE_CREATED = 'message.created' diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb index 3ff5fd0a2..43ec3bb55 100644 --- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb @@ -723,6 +723,19 @@ RSpec.describe 'Conversations API', type: :request do create(:message, conversation: conversation, account: account, inbox: conversation.inbox, content: 'Hello', message_type: 'incoming') end + it 'dispatches conversation.unread event' do + freeze_time + allow(Rails.configuration.dispatcher).to receive(:dispatch) + .with(Events::Types::CONVERSATION_UNREAD, Time.zone.now, conversation: conversation) + + post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/unread", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(Rails.configuration.dispatcher).to have_received(:dispatch) + end + it 'updates last seen' do post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/unread", headers: agent.create_new_auth_token, diff --git a/spec/listeners/channel_listener_spec.rb b/spec/listeners/channel_listener_spec.rb index b0574246f..f143d2a10 100644 --- a/spec/listeners/channel_listener_spec.rb +++ b/spec/listeners/channel_listener_spec.rb @@ -60,6 +60,20 @@ describe ChannelListener do end end + describe '#conversation_unread' do + let(:channel) { create(:channel_whatsapp, sync_templates: false, validate_provider_config: false) } + let(:conversation) { create(:conversation, inbox: create(:inbox, channel: channel)) } + let(:event) { Events::Base.new(Events::Types::CONVERSATION_UNREAD, Time.zone.now, conversation: conversation) } + + it 'calls unread_conversation on the channel' do + allow(channel).to receive(:unread_conversation).with(conversation) + + listener.conversation_unread(event) + + expect(channel).to have_received(:unread_conversation) + end + end + describe '#account_presence_updated' do let(:account_user) { create(:account_user) } let(:inbox) { create(:inbox, account: account_user.account) } @@ -101,11 +115,11 @@ describe ChannelListener do create(:message, conversation: conversation, message_type: :incoming, status: :read) sent_message = create(:message, conversation: conversation, message_type: :incoming, status: :sent) - allow(channel).to receive(:send_read_messages).with([sent_message], conversation: conversation) + allow(channel).to receive(:read_messages).with([sent_message], conversation: conversation) listener.messages_read(event) - expect(channel).to have_received(:send_read_messages) + expect(channel).to have_received(:read_messages) end it 'skips the event if the channel does not respond to send_read_messages' do @@ -119,33 +133,33 @@ describe ChannelListener do it 'skips the event if there are no unread messages' do create(:message, conversation: conversation, message_type: :incoming, status: :read) - allow(channel).to receive(:send_read_messages) + allow(channel).to receive(:read_messages) listener.messages_read(event) - expect(channel).not_to have_received(:send_read_messages) + expect(channel).not_to have_received(:read_messages) end it 'filters messages ignoring last_seen_at' do old_message = create(:message, conversation: conversation, message_type: :incoming, status: :sent, updated_at: last_seen_at - 1.day) recent_message = create(:message, conversation: conversation, message_type: :incoming, status: :sent, updated_at: Time.zone.now) - allow(channel).to receive(:send_read_messages).with([old_message, recent_message], conversation: conversation) + allow(channel).to receive(:read_messages).with([old_message, recent_message], conversation: conversation) listener.messages_read(Events::Base.new(Events::Types::MESSAGES_READ, Time.zone.now, conversation: conversation, last_seen_at: nil)) - expect(channel).to have_received(:send_read_messages) + expect(channel).to have_received(:read_messages) end it 'filters messages based on last_seen_at' do create(:message, conversation: conversation, message_type: :incoming, status: :sent, updated_at: last_seen_at - 1.day) recent_message = create(:message, conversation: conversation, message_type: :incoming, status: :sent, updated_at: Time.zone.now) - allow(channel).to receive(:send_read_messages).with([recent_message], conversation: conversation) + allow(channel).to receive(:read_messages).with([recent_message], conversation: conversation) listener.messages_read(event) - expect(channel).to have_received(:send_read_messages) + expect(channel).to have_received(:read_messages) end end diff --git a/spec/models/channel/whatsapp_spec.rb b/spec/models/channel/whatsapp_spec.rb index 67abf2bd3..8b6606945 100644 --- a/spec/models/channel/whatsapp_spec.rb +++ b/spec/models/channel/whatsapp_spec.rb @@ -120,21 +120,21 @@ RSpec.describe Channel::Whatsapp do end end - describe '#send_read_messages' do + describe '#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) + provider_double = instance_double(Whatsapp::Providers::WhatsappBaileysService, read_messages: nil) + allow(provider_double).to receive(: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) + channel.read_messages([message], conversation: conversation) - expect(provider_double).to have_received(:send_read_messages) + expect(provider_double).to have_received(:read_messages) end it 'does not call method if provider service does not implement it' do @@ -145,11 +145,50 @@ RSpec.describe Channel::Whatsapp do .and_return(provider_double) expect do - channel.send_read_messages([message], conversation: conversation) + channel.read_messages([message], conversation: conversation) end.not_to raise_error end end + describe '#unread_conversation' do + let(:channel) { create(:channel_whatsapp, provider: 'baileys', validate_provider_config: false, sync_templates: false) } + let(:conversation) { create(:conversation) } + + it 'calls provider service method' do + message = create(:message, conversation: conversation) + provider_double = instance_double(Whatsapp::Providers::WhatsappBaileysService, unread_message: nil) + allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new).with(whatsapp_channel: channel).and_return(provider_double) + allow(provider_double).to receive(:unread_message).with(conversation.contact.phone_number, [message]) + + channel.unread_conversation(conversation) + + expect(provider_double).to have_received(:unread_message) + end + + it 'does not call method if provider service does not implement it' do + # NOTE: This message ensures that there are messages but the provider does not implement the method. + create(:message, conversation: conversation) + + provider_double = instance_double(Whatsapp::Providers::WhatsappBaileysService) + allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new).with(whatsapp_channel: channel).and_return(provider_double) + + expect do + channel.unread_conversation(conversation) + end + .not_to raise_error + end + + it 'does not call method if there are no messages' do + provider_double = instance_double(Whatsapp::Providers::WhatsappBaileysService, unread_message: nil) + allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new).with(whatsapp_channel: channel).and_return(provider_double) + allow(provider_double).to receive(:unread_message) + + channel.unread_conversation(conversation) + + expect(provider_double).not_to have_received(:unread_message) + 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 c4d5f0c61..33d0f856e 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 @@ describe Whatsapp::Providers::WhatsappBaileysService do 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, source_id: 'msg_123') } + let(:message) { create(:message, source_id: 'msg_123', content_attributes: { external_created_at: 123 }) } let(:test_send_phone_number) { '551187654321' } let(:test_send_jid) { '551187654321@s.whatsapp.net' } @@ -371,7 +371,7 @@ describe Whatsapp::Providers::WhatsappBaileysService do end end - describe '#send_read_messages' do + describe '#read_messages' do it 'send read messages request' do stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/read-messages") .with( @@ -379,7 +379,30 @@ describe Whatsapp::Providers::WhatsappBaileysService do body: { keys: [{ id: message.source_id, remoteJid: test_send_jid, fromMe: false }] }.to_json ).to_return(status: 200, body: '', headers: {}) - result = service.send_read_messages(test_send_phone_number, [message]) + result = service.read_messages(test_send_phone_number, [message]) + + expect(result).to be(true) + end + end + + describe '#unread_message' do + it 'send unread message request' do + stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/chat-modify") + .with( + headers: stub_headers(whatsapp_channel), + body: { + jid: test_send_jid, + mod: { + markRead: false, + lastMessages: { + key: { id: 'msg_123', remoteJid: test_send_jid, fromMe: false }, + messageTimestamp: 123 + } + } + }.to_json + ).to_return(status: 200) + + result = service.unread_message(test_send_phone_number, message) expect(result).to be(true) end