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
This commit is contained in:
Cayo P. R. Oliveira 2025-04-09 19:14:38 -03:00 committed by GitHub
parent 22c8ea8265
commit 25670564eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 239 additions and 8 deletions

View File

@ -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])

View File

@ -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

View File

@ -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

View File

@ -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)