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)