From eaf2f995205d66c3a18f46cd630cd3b52d333cf8 Mon Sep 17 00:00:00 2001 From: Gabriel Jablonski Date: Tue, 2 Sep 2025 15:20:13 -0300 Subject: [PATCH] feat: whatsapp cloud service typing status and read messages (#106) --- app/models/channel/whatsapp.rb | 5 +- .../providers/whatsapp_baileys_service.rb | 12 +-- .../providers/whatsapp_cloud_service.rb | 41 ++++++++- spec/models/channel/whatsapp_spec.rb | 8 +- .../whatsapp_baileys_service_spec.rb | 14 +-- .../providers/whatsapp_cloud_service_spec.rb | 87 +++++++++++++++++++ 6 files changed, 146 insertions(+), 21 deletions(-) diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb index 7ce8fd908..bd312c1a3 100644 --- a/app/models/channel/whatsapp.rb +++ b/app/models/channel/whatsapp.rb @@ -84,7 +84,8 @@ class Channel::Whatsapp < ApplicationRecord def toggle_typing_status(typing_status, conversation:) return unless provider_service.respond_to?(:toggle_typing_status) - provider_service.toggle_typing_status(conversation.contact.phone_number, typing_status) + last_message = conversation.messages.last + provider_service.toggle_typing_status(typing_status, last_message: last_message, phone_number: conversation.contact.phone_number) end def update_presence(status) @@ -98,7 +99,7 @@ class Channel::Whatsapp < ApplicationRecord # NOTE: This is the default behavior, so `mark_as_read` being `nil` is the same as `true`. return if provider_config&.dig('mark_as_read') == false - provider_service.read_messages(conversation.contact.phone_number, messages) + provider_service.read_messages(messages, phone_number: conversation.contact.phone_number) end def unread_conversation(conversation) diff --git a/app/services/whatsapp/providers/whatsapp_baileys_service.rb b/app/services/whatsapp/providers/whatsapp_baileys_service.rb index 0c97a9190..d85cf6c14 100644 --- a/app/services/whatsapp/providers/whatsapp_baileys_service.rb +++ b/app/services/whatsapp/providers/whatsapp_baileys_service.rb @@ -94,7 +94,7 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer process_response(response) end - def toggle_typing_status(phone_number, typing_status) + def toggle_typing_status(typing_status, phone_number:, **) @phone_number = phone_number status_map = { Events::Types::CONVERSATION_TYPING_ON => 'composing', @@ -136,7 +136,7 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer true end - def read_messages(phone_number, messages) + def read_messages(messages, phone_number:, **) @phone_number = phone_number response = HTTParty.post( @@ -319,12 +319,12 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer method_names.each do |method_name| original_method = instance_method(method_name) - define_method("#{method_name}_without_error_handling") do |*args, &block| - original_method.bind_call(self, *args, &block) + define_method("#{method_name}_without_error_handling") do |*args, **kwargs, &block| + original_method.bind_call(self, *args, **kwargs, &block) end - define_method(method_name) do |*args, &block| - original_method.bind_call(self, *args, &block) + define_method(method_name) do |*args, **kwargs, &block| + original_method.bind_call(self, *args, **kwargs, &block) rescue StandardError => e handle_channel_error raise e diff --git a/app/services/whatsapp/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb index d3f47e356..8ce7af12a 100644 --- a/app/services/whatsapp/providers/whatsapp_cloud_service.rb +++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb @@ -71,8 +71,8 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi end # TODO: See if we can unify the API versions and for both paths and make it consistent with out facebook app API versions - def phone_id_path - "#{api_base_path}/v13.0/#{whatsapp_channel.provider_config['phone_number_id']}" + def phone_id_path(version = 'v13.0') + "#{api_base_path}/#{version}/#{whatsapp_channel.provider_config['phone_number_id']}" end def business_account_path @@ -181,4 +181,41 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi process_response(response, message) end + + def toggle_typing_status(typing_status, last_message:, **) + return false unless [Events::Types::CONVERSATION_TYPING_ON, Events::Types::CONVERSATION_RECORDING].include?(typing_status) + + response = HTTParty.post( + "#{phone_id_path('v23.0')}/messages", + headers: api_headers, + body: { + messaging_product: 'whatsapp', + message_id: last_message.source_id, + # NOTE: API currently only supports "typing", no "recording" status. + typing_indicator: { type: 'text' } + }.to_json + ) + + Rails.logger.error(response.parsed_response) unless response.success? + + response.success? + end + + def read_messages(messages, **) + # NOTE: Marking the last message as read automatically applies to all previous ones. + message = messages.last + response = HTTParty.post( + "#{phone_id_path('v23.0')}/messages", + headers: api_headers, + body: { + messaging_product: 'whatsapp', + message_id: message.source_id, + status: 'read' + }.to_json + ) + + Rails.logger.error(response.parsed_response) unless response.success? + + response.success? + end end diff --git a/spec/models/channel/whatsapp_spec.rb b/spec/models/channel/whatsapp_spec.rb index be9d6476e..c3767e9c9 100644 --- a/spec/models/channel/whatsapp_spec.rb +++ b/spec/models/channel/whatsapp_spec.rb @@ -186,7 +186,7 @@ RSpec.describe Channel::Whatsapp do 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) + .with(Events::Types::CONVERSATION_TYPING_ON, phone_number: conversation.contact.phone_number) allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new) .with(whatsapp_channel: channel) .and_return(provider_double) @@ -246,7 +246,7 @@ RSpec.describe Channel::Whatsapp do it 'calls provider service method' do provider_double = instance_double(Whatsapp::Providers::WhatsappBaileysService, read_messages: nil) - allow(provider_double).to receive(:read_messages).with([message], conversation.contact.phone_number) + allow(provider_double).to receive(:read_messages).with([message], phone_number: conversation.contact.phone_number) allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new) .with(whatsapp_channel: channel) .and_return(provider_double) @@ -259,7 +259,7 @@ RSpec.describe Channel::Whatsapp do it 'call method when the provider config mark_as_read is nil' do channel.update!(provider_config: {}) provider_double = instance_double(Whatsapp::Providers::WhatsappBaileysService, read_messages: nil) - allow(provider_double).to receive(:read_messages).with([message], conversation.contact.phone_number) + allow(provider_double).to receive(:read_messages).with([message], phone_number: conversation.contact.phone_number) allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new) .with(whatsapp_channel: channel) .and_return(provider_double) @@ -270,7 +270,7 @@ RSpec.describe Channel::Whatsapp do end it 'does not call method if provider service does not implement it' do - channel.update!(provider: 'whatsapp_cloud') + channel.update!(provider: 'default') expect do channel.read_messages([message], conversation: conversation) diff --git a/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb b/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb index ef5eecd54..e8e778203 100644 --- a/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb +++ b/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb @@ -477,7 +477,7 @@ 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.read_messages(test_send_phone_number, [message]) + result = service.read_messages([message], phone_number: test_send_phone_number) expect(result).to be(true) end @@ -495,7 +495,7 @@ describe Whatsapp::Providers::WhatsappBaileysService do .to_return(status: 200) expect do - service.read_messages(test_send_phone_number, [message]) + service.read_messages([message], phone_number: test_send_phone_number) end.to raise_error(Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError) end end @@ -605,7 +605,7 @@ describe Whatsapp::Providers::WhatsappBaileysService do ) .to_return(status: 200) - service.toggle_typing_status(test_send_phone_number, Events::Types::CONVERSATION_TYPING_ON) + service.toggle_typing_status(Events::Types::CONVERSATION_TYPING_ON, phone_number: test_send_phone_number) expect(request).to have_been_requested end @@ -621,7 +621,7 @@ describe Whatsapp::Providers::WhatsappBaileysService do ) .to_return(status: 200) - service.toggle_typing_status(test_send_phone_number, Events::Types::CONVERSATION_RECORDING) + service.toggle_typing_status(Events::Types::CONVERSATION_RECORDING, phone_number: test_send_phone_number) expect(request).to have_been_requested end @@ -637,7 +637,7 @@ describe Whatsapp::Providers::WhatsappBaileysService do ) .to_return(status: 200) - service.toggle_typing_status(test_send_phone_number, Events::Types::CONVERSATION_TYPING_OFF) + service.toggle_typing_status(Events::Types::CONVERSATION_TYPING_OFF, phone_number: test_send_phone_number) expect(request).to have_been_requested end @@ -664,7 +664,7 @@ describe Whatsapp::Providers::WhatsappBaileysService do allow(Rails.logger).to receive(:error) expect do - service.toggle_typing_status(test_send_phone_number, Events::Types::CONVERSATION_TYPING_ON) + service.toggle_typing_status(Events::Types::CONVERSATION_TYPING_ON, phone_number: test_send_phone_number) end.to raise_error(Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError) expect(Rails.logger).to have_received(:error).with('error message') @@ -916,7 +916,7 @@ describe Whatsapp::Providers::WhatsappBaileysService do whatsapp_channel.update!(provider_connection: { 'connection' => 'open' }) expect do - service.toggle_typing_status(test_send_phone_number, Events::Types::CONVERSATION_TYPING_ON) + service.toggle_typing_status(Events::Types::CONVERSATION_TYPING_ON, phone_number: test_send_phone_number) end.to raise_error(Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError) expect(whatsapp_channel.reload.provider_connection['connection']).to eq('close') diff --git a/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb b/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb index 69ba69379..f55ed03f3 100644 --- a/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb +++ b/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb @@ -312,4 +312,91 @@ describe Whatsapp::Providers::WhatsappCloudService do end end end + + describe '#toggle_typing_status' do + let(:conversation) { create(:conversation) } + + it 'calls messages endpoint with typing indicator for "conversation.typing_on"' do + stub_request(:post, 'https://graph.facebook.com/v23.0/123456789/messages') + .with( + body: { + messaging_product: 'whatsapp', + message_id: message.source_id, + typing_indicator: { type: 'text' } + }.to_json + ) + .to_return(status: 200, body: { success: true }.to_json, headers: response_headers) + + expect(service.toggle_typing_status(Events::Types::CONVERSATION_TYPING_ON, last_message: message)).to be(true) + end + + it 'calls messages endpoint with typing indicator for "conversation.recording"' do + stub_request(:post, 'https://graph.facebook.com/v23.0/123456789/messages') + .with( + body: { + messaging_product: 'whatsapp', + message_id: message.source_id, + typing_indicator: { type: 'text' } + }.to_json + ) + .to_return(status: 200, body: { success: true }.to_json, headers: response_headers) + + expect(service.toggle_typing_status(Events::Types::CONVERSATION_RECORDING, last_message: message)).to be(true) + end + + it 'does not call messages endpoint with typing indicator for "conversation.typing_off"' do + expect(service.toggle_typing_status(Events::Types::CONVERSATION_TYPING_OFF, last_message: message)).to be(false) + end + + it 'logs error on failure' do + allow(Rails.logger).to receive(:error).with('Request failed') + stub_request(:post, 'https://graph.facebook.com/v23.0/123456789/messages') + .with( + body: { + messaging_product: 'whatsapp', + message_id: message.source_id, + typing_indicator: { type: 'text' } + }.to_json + ) + .to_return(status: 500, body: 'Request failed') + + service.toggle_typing_status(Events::Types::CONVERSATION_TYPING_ON, last_message: message) + + expect(Rails.logger).to have_received(:error) + end + end + + describe '#read_messages' do + it 'calls messages endpoint to mark last message as read' do + stub_request(:post, 'https://graph.facebook.com/v23.0/123456789/messages') + .with( + body: { + messaging_product: 'whatsapp', + message_id: message.source_id, + status: 'read' + }.to_json + ) + .to_return(status: 200, body: { success: true }.to_json, headers: response_headers) + + messages = [create(:message), message] + expect(service.read_messages(messages)).to be(true) + end + + it 'logs error on failure' do + allow(Rails.logger).to receive(:error).with('Request failed') + stub_request(:post, 'https://graph.facebook.com/v23.0/123456789/messages') + .with( + body: { + messaging_product: 'whatsapp', + message_id: message.source_id, + status: 'read' + }.to_json + ) + .to_return(status: 500, body: 'Request failed') + + service.read_messages([message]) + + expect(Rails.logger).to have_received(:error) + end + end end