From 25670564eb2999cefebd4f870298183adb870c27 Mon Sep 17 00:00:00 2001 From: "Cayo P. R. Oliveira" Date: Wed, 9 Apr 2025 19:14:38 -0300 Subject: [PATCH] feat: add message update processing in IncomingMessageBaileysService (#20) * feat: add message update processing in IncomingMessageBaileysService * feat: implement message update handling in IncomingMessageBaileysService * feat: add MessageNotFoundError for handling invalid update messages in IncomingMessageBaileysService * chore: specs for message.update events and bug fixes * chore: refactor message update handling in IncomingMessageBaileysService * chore: nit remove redundant comment * chore: enhance logging for unsupported message update statuses in IncomingMessageBaileysService and disabled metrics * chore: message status update logic with transition checks * chore: update status mapping for PENDING to sent in status_mapper * chore: update status_mapper comments and fix case statement for status codes * fix: logging for unsupported message updates in update_message_content method * test: add specs for unsupported status transitions in messages.update event * refactor: ensure message status is reloaded before assertion in messages.update event spec * refactor: status variable in status_mapper method * refactor: rename status_transition_allowed method to status_transition_allowed? * refactor: streamline message creation in specs by using let! for setup * feat: process webhook whatsapp await response (#21) * feat: enhance WhatsApp webhook processing and error responses handling * chore: correct spelling of 'WhatsApp' in webhook controller specs * refactor: rename webhook processing method and improve error handling * chore: update error handling in WhatsApp controller specs for specific exceptions * refactor: remove handling for StandardError in WhatsApp controller specs * refactor: simplify perform_whatsapp_events_job method * chore: update response status from not_found to bad_request for invalid message * refactor: update expectations for job processing in WhatsApp controller specs --- .../webhooks/whatsapp_controller.rb | 17 ++- .../incoming_message_baileys_service.rb | 74 +++++++++++- .../webhooks/whatsapp_controller_spec.rb | 43 ++++++- .../incoming_message_baileys_service_spec.rb | 113 ++++++++++++++++++ 4 files changed, 239 insertions(+), 8 deletions(-) diff --git a/app/controllers/webhooks/whatsapp_controller.rb b/app/controllers/webhooks/whatsapp_controller.rb index c4c376e5c..78a5a3bef 100644 --- a/app/controllers/webhooks/whatsapp_controller.rb +++ b/app/controllers/webhooks/whatsapp_controller.rb @@ -8,11 +8,26 @@ class Webhooks::WhatsappController < ActionController::API return end + perform_whatsapp_events_job + end + + private + + def perform_whatsapp_events_job + perform_sync if params[:awaitResponse].present? + return if performed? + Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash) head :ok end - private + def perform_sync + Webhooks::WhatsappEventsJob.perform_now(params.to_unsafe_hash) + rescue Whatsapp::IncomingMessageBaileysService::InvalidWebhookVerifyToken + head :unauthorized + rescue Whatsapp::IncomingMessageBaileysService::MessageNotFoundError + head :bad_request + end def valid_token?(token) channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number]) diff --git a/app/services/whatsapp/incoming_message_baileys_service.rb b/app/services/whatsapp/incoming_message_baileys_service.rb index 34a0fc296..a842a09b5 100644 --- a/app/services/whatsapp/incoming_message_baileys_service.rb +++ b/app/services/whatsapp/incoming_message_baileys_service.rb @@ -1,5 +1,6 @@ -class Whatsapp::IncomingMessageBaileysService < Whatsapp::IncomingMessageBaseService +class Whatsapp::IncomingMessageBaileysService < Whatsapp::IncomingMessageBaseService # rubocop:disable Metrics/ClassLength class InvalidWebhookVerifyToken < StandardError; end + class MessageNotFoundError < StandardError; end def perform raise InvalidWebhookVerifyToken if processed_params[:webhookVerifyToken] != inbox.channel.provider_config['webhook_verify_token'] @@ -171,4 +172,75 @@ class Whatsapp::IncomingMessageBaileysService < Whatsapp::IncomingMessageBaseSer key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: message_id) ::Redis::Alfred.delete(key) end + + def process_messages_update + updates = processed_params[:data] + updates.each do |update| + @message = nil + @raw_message = update + handle_update + end + end + + def handle_update + raise MessageNotFoundError unless valid_update_message? + + 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) + end + + def status_mapper + # NOTE: Baileys status codes vs. Chatwoot support: + # - (0) ERROR → (3) failed + # - (1) PENDING → (0) sent + # - (2) SERVER_ACK → (0) sent + # - (3) DELIVERY_ACK → (1) delivered + # - (4) READ → (2) read + # - (5) PLAYED → (unsupported: PLAYED) + # For details: https://github.com/WhiskeySockets/Baileys/blob/v6.7.16/WAProto/index.d.ts#L36694 + status = @raw_message.dig(:update, :status) + case status + when 0 + 'failed' + when 1, 2 + 'sent' + when 3 + 'delivered' + when 4 + 'read' + when 5 + Rails.logger.warn 'Baileys unsupported message update status: PLAYED(5)' + else + Rails.logger.warn "Baileys unsupported message update status: #{status}" + end + end + + def status_transition_allowed?(new_status) + return false if @message.status == 'read' + return false if @message.status == 'delivered' && new_status == 'sent' + + true + end + + def update_message_content + message = @raw_message.dig(:update, :message, :editedMessage, :message) + if message.blank? + Rails.logger.warn 'No valid message content found in the update event' + return + end + + content = message[:conversation] || message.dig(:extendedTextMessage, :text) + + @message.update!(content: content) if content.present? + end end diff --git a/spec/controllers/webhooks/whatsapp_controller_spec.rb b/spec/controllers/webhooks/whatsapp_controller_spec.rb index e5fa392b0..296461df4 100644 --- a/spec/controllers/webhooks/whatsapp_controller_spec.rb +++ b/spec/controllers/webhooks/whatsapp_controller_spec.rb @@ -23,11 +23,13 @@ RSpec.describe 'Webhooks::WhatsappController', type: :request do end describe 'POST /webhooks/whatsapp/{:phone_number}' do - it 'call the whatsapp events job with the params' do + it 'calls the whatsapp events job asynchronously with perform_later when awaitResponse is not present' do allow(Webhooks::WhatsappEventsJob).to receive(:perform_later) - expect(Webhooks::WhatsappEventsJob).to receive(:perform_later) + post '/webhooks/whatsapp/123221321', params: { content: 'hello' } - expect(response).to have_http_status(:success) + + expect(Webhooks::WhatsappEventsJob).to have_received(:perform_later) + expect(response).to have_http_status(:ok) end context 'when phone number is in inactive list' do @@ -37,9 +39,10 @@ RSpec.describe 'Webhooks::WhatsappController', type: :request do it 'returns service unavailable for inactive phone number in URL params' do allow(Rails.logger).to receive(:warn) - expect(Rails.logger).to receive(:warn).with('Rejected webhook for inactive WhatsApp number: +1234567890') post '/webhooks/whatsapp/+1234567890', params: { content: 'hello' } + + expect(Rails.logger).to have_received(:warn).with('Rejected webhook for inactive WhatsApp number: +1234567890') expect(response).to have_http_status(:unprocessable_entity) expect(response.parsed_body['error']).to eq('Inactive WhatsApp number') end @@ -52,10 +55,38 @@ RSpec.describe 'Webhooks::WhatsappController', type: :request do it 'processes the webhook normally' do allow(Webhooks::WhatsappEventsJob).to receive(:perform_later) - expect(Webhooks::WhatsappEventsJob).to receive(:perform_later) post '/webhooks/whatsapp/+1234567890', params: { content: 'hello' } - expect(response).to have_http_status(:success) + + expect(Webhooks::WhatsappEventsJob).to have_received(:perform_later) + expect(response).to have_http_status(:ok) + end + end + + context 'when awaitResponse param is present' do + it 'calls the whatsapp events job synchronously' do + allow(Webhooks::WhatsappEventsJob).to receive(:perform_now) + + post '/webhooks/whatsapp/123221321', params: { content: 'hello', awaitResponse: true } + + expect(Webhooks::WhatsappEventsJob).to have_received(:perform_now) + expect(response).to have_http_status(:ok) + end + + it 'returns 401 when InvalidWebhookVerifyToken is raised' do + allow(Webhooks::WhatsappEventsJob).to receive(:perform_now).and_raise(Whatsapp::IncomingMessageBaileysService::InvalidWebhookVerifyToken) + + post '/webhooks/whatsapp/123221321', params: { content: 'hello', awaitResponse: true } + + expect(response).to have_http_status(:unauthorized) + end + + it 'returns 400 when MessageNotFoundError is raised' do + allow(Webhooks::WhatsappEventsJob).to receive(:perform_now).and_raise(Whatsapp::IncomingMessageBaileysService::MessageNotFoundError) + + post '/webhooks/whatsapp/123221321', params: { content: 'hello', awaitResponse: true } + + expect(response).to have_http_status(:bad_request) end end end diff --git a/spec/services/whatsapp/incoming_message_baileys_service_spec.rb b/spec/services/whatsapp/incoming_message_baileys_service_spec.rb index 4bc7ab5bd..3e50371f9 100644 --- a/spec/services/whatsapp/incoming_message_baileys_service_spec.rb +++ b/spec/services/whatsapp/incoming_message_baileys_service_spec.rb @@ -320,6 +320,119 @@ describe Whatsapp::IncomingMessageBaileysService do end end end + + context 'when processing messages.update event' do + context 'when message is not found' do + let(:message_id) { 'msg_123' } + let(:update_payload) do + { + key: { id: message_id }, + update: { + status: 2 + } + } + end + + it 'raises MessageNotFoundError' do + params = { + webhookVerifyToken: webhook_verify_token, + event: 'messages.update', + data: [update_payload] + } + + expect do + described_class.new(inbox: inbox, params: params).perform + end.to raise_error(Whatsapp::IncomingMessageBaileysService::MessageNotFoundError) + end + end + + context 'when message is found' do + let(:message_id) { 'msg_123' } + let!(:message) { create(:message, source_id: message_id, status: 'sent') } + + it 'updates the message status' do + update_payload = { key: { id: message_id }, update: { status: 3 } } + params = { + webhookVerifyToken: webhook_verify_token, + event: 'messages.update', + data: [update_payload] + } + + described_class.new(inbox: inbox, params: params).perform + + expect(message.reload.status).to eq('delivered') + end + + it 'updates the message content' do + update_payload = { + key: { id: message_id }, + update: { + message: { editedMessage: { message: { conversation: 'New message content' } } } + } + } + params = { + webhookVerifyToken: webhook_verify_token, + event: 'messages.update', + data: [update_payload] + } + + described_class.new(inbox: inbox, params: params).perform + + expect(message.reload.content).to eq('New message content') + end + end + + context 'when the status transition is not allowed (message already read)' do + let(:message_id) { 'msg_123' } + let!(:message) { create(:message, source_id: message_id, status: 'read') } + + it 'does not update the status' do + update_payload = { key: { id: message_id }, update: { status: 3 } } + params = { + webhookVerifyToken: webhook_verify_token, + event: 'messages.update', + data: [update_payload] + } + + described_class.new(inbox: inbox, params: params).perform + + expect(message.reload.status).to eq('read') + end + end + + context 'when update unsupported status' do + let(:message_id) { 'msg_123' } + let!(:message) { create(:message, source_id: message_id) } # rubocop:disable RSpec/LetSetup + + it 'logs warning for unsupported played status' do + update_payload = { key: { id: message_id }, update: { status: 5 } } + params = { + webhookVerifyToken: webhook_verify_token, + event: 'messages.update', + data: [update_payload] + } + allow(Rails.logger).to receive(:warn).with('Baileys unsupported message update status: PLAYED(5)') + + described_class.new(inbox: inbox, params: params).perform + + expect(Rails.logger).to have_received(:warn) + end + + it 'logs warning for unsupported status' do + update_payload = { key: { id: message_id }, update: { status: 6 } } + params = { + webhookVerifyToken: webhook_verify_token, + event: 'messages.update', + data: [update_payload] + } + allow(Rails.logger).to receive(:warn).with('Baileys unsupported message update status: 6') + + described_class.new(inbox: inbox, params: params).perform + + expect(Rails.logger).to have_received(:warn) + end + end + end end def format_message_source_key(message_id)