From d2a0d53fab6ba2cdb81921415c93053396e61790 Mon Sep 17 00:00:00 2001 From: Gabriel Coelho Date: Wed, 27 Aug 2025 13:02:04 -0300 Subject: [PATCH] feat(whatsapp): get contact profile picture when using Baileys service (#98) * feat(whatsapp): add profile picture fetching for Baileys provider - Add `get_profile_pic` method to `WhatsappBaileysService` for fetching contact avatars - Include `avatar_url` in contact attributes during contact creation - Update `MessagesUpsert` handler to fetch and set profile pictures - Add proper error handling and graceful degradation when avatar fetching fails * test(whatsapp): add test coverage for Baileys profile picture fetching - Add test suite for `get_profile_pic` method - Include test scenarios for successful responses, missing avatars, and error handling - Add profile picture HTTP stubbing and avatar job expectations to message processing tests * feat(whatsapp): optimize Baileys avatar fetching and update avatar for existing inboxes Previously, the profile picture was fetched on every incoming message and avatar updates were only attempted for new inboxes. This change optimizes the process by: - Only fetching the WhatsApp profile picture if the contact does not already have an avatar. - Attempting to update the avatar for both new and existing inboxes. This logic is based on Telegram's version (app/services/telegram/incoming_message_service.rb). * refactor(whatsapp): remove unnecessary safe navigation when accessing contact This reverts an unnecessary change introduced in b918a92. * refactor(whatsapp): manually rollback change introduced in b918a92 * refactor(whatsapp): rename endpoint to profile-picture-url * refactor(whatsapp): simplify profile picture response handling Replaced manual JSON parsing with HTTParty's parsed_response and removed redundant JSON parsing tests. * test(whatsapp): separate avatar processing test from message creation test * test(whatsapp): update profile picture test to match actual API behavior Change test to expect a 404 response when no profile picture exists, instead of a 200 response with partial data. * refactor: move new functions to helper module * test: more specs --------- Co-authored-by: gabrieljablonski --- .../whatsapp/baileys_handlers/helpers.rb | 17 +++++++ .../baileys_handlers/messages_upsert.rb | 1 + .../providers/whatsapp_baileys_service.rb | 13 +++++ .../incoming_message_baileys_service_spec.rb | 51 +++++++++++++++++++ .../whatsapp_baileys_service_spec.rb | 45 ++++++++++++++++ 5 files changed, 127 insertions(+) diff --git a/app/services/whatsapp/baileys_handlers/helpers.rb b/app/services/whatsapp/baileys_handlers/helpers.rb index 76db37756..6dd74f070 100644 --- a/app/services/whatsapp/baileys_handlers/helpers.rb +++ b/app/services/whatsapp/baileys_handlers/helpers.rb @@ -143,6 +143,23 @@ module Whatsapp::BaileysHandlers::Helpers # rubocop:disable Metrics/ModuleLength (message_type == 'reaction' && message_content.blank?) end + def fetch_profile_picture_url(phone_number) + jid = "#{phone_number}@s.whatsapp.net" + response = inbox.channel.provider_service.get_profile_pic(jid) + response&.dig('data', 'profilePictureUrl') + rescue StandardError => e + Rails.logger.error "Failed to fetch profile picture for #{phone_number}: #{e.message}" + nil + end + + def try_update_contact_avatar + # TODO: Current logic will never update the contact avatar if their profile picture changes on WhatsApp. + return if @contact.avatar.attached? + + profile_pic_url = fetch_profile_picture_url(phone_number_from_jid) + ::Avatar::AvatarFromUrlJob.perform_later(@contact, profile_pic_url) if profile_pic_url + end + def message_under_process? key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: raw_message_id) Redis::Alfred.get(key) diff --git a/app/services/whatsapp/baileys_handlers/messages_upsert.rb b/app/services/whatsapp/baileys_handlers/messages_upsert.rb index d7742f874..ceef9e8f2 100644 --- a/app/services/whatsapp/baileys_handlers/messages_upsert.rb +++ b/app/services/whatsapp/baileys_handlers/messages_upsert.rb @@ -55,6 +55,7 @@ module Whatsapp::BaileysHandlers::MessagesUpsert @contact = contact_inbox.contact @contact.update!(name: push_name) if @contact.name == source_id + try_update_contact_avatar end def handle_create_message diff --git a/app/services/whatsapp/providers/whatsapp_baileys_service.rb b/app/services/whatsapp/providers/whatsapp_baileys_service.rb index 0023680de..babd34b43 100644 --- a/app/services/whatsapp/providers/whatsapp_baileys_service.rb +++ b/app/services/whatsapp/providers/whatsapp_baileys_service.rb @@ -189,6 +189,19 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer true end + def get_profile_pic(jid) + response = HTTParty.get( + "#{provider_url}/connections/#{whatsapp_channel.phone_number}/profile-picture-url", + headers: api_headers, + query: { jid: jid }, + format: :json + ) + + return nil unless process_response(response) + + response.parsed_response + end + def on_whatsapp(phone_number) @phone_number = phone_number diff --git a/spec/services/whatsapp/incoming_message_baileys_service_spec.rb b/spec/services/whatsapp/incoming_message_baileys_service_spec.rb index f24d5f384..addefff34 100644 --- a/spec/services/whatsapp/incoming_message_baileys_service_spec.rb +++ b/spec/services/whatsapp/incoming_message_baileys_service_spec.rb @@ -146,6 +146,9 @@ describe Whatsapp::IncomingMessageBaileysService do end context 'when processing messages.upsert event' do + # NOTE: We always try fetching profile picture for new contacts on messages.upsert, so we must stub this request. + before { stub_profile_pic_fetch } + let(:timestamp) { Time.current.to_i } let(:raw_message) do { @@ -176,6 +179,46 @@ describe Whatsapp::IncomingMessageBaileysService do expect(message.content_attributes[:external_created_at]).to eq(timestamp) end + context 'when updating contact avatar' do + it 'enqueues avatar job when profile picture is available' do + stub_profile_pic_fetch('https://example.com/avatar.jpg') + + described_class.new(inbox: inbox, params: params).perform + + conversation = inbox.conversations.last + contact = conversation.contact + + expect(Avatar::AvatarFromUrlJob).to have_been_enqueued.with(contact, 'https://example.com/avatar.jpg') + end + + it 'does not enqueue avatar job when contact already has avatar attached' do + stub_profile_pic_fetch('https://example.com/avatar.jpg') + contact = create(:contact, account: inbox.account, name: 'John Doe', phone_number: '+5511912345678') + contact.avatar.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png') + + described_class.new(inbox: inbox, params: params).perform + + expect(Avatar::AvatarFromUrlJob).not_to have_been_enqueued + end + + it 'does not enqueue avatar job when profile picture is not available' do + described_class.new(inbox: inbox, params: params).perform + + expect(Avatar::AvatarFromUrlJob).not_to have_been_enqueued + end + + it 'logs error and does not enqueue avatar job when profile picture request fails' do + allow(Rails.logger).to receive(:error) + stub_request(:get, /profile-picture-url/) + .to_raise(StandardError.new('Profile picture request failed')) + + described_class.new(inbox: inbox, params: params).perform + + expect(Avatar::AvatarFromUrlJob).not_to have_been_enqueued + expect(Rails.logger).to have_received(:error).with('Failed to fetch profile picture for 5511912345678: Profile picture request failed') + end + end + context 'when message type is unsupported' do it 'creates message with is_unsupported' do raw_message[:message] = { unsupported: 'message' } @@ -829,4 +872,12 @@ describe Whatsapp::IncomingMessageBaileysService do .with('https://baileys.api/media/msg_123', headers: inbox.channel.api_headers) .and_return(StringIO.new('Media data')) end + + def stub_profile_pic_fetch(url = nil) + stub_request(:get, /profile-picture-url/) + .to_return( + status: 200, + body: { data: { profilePictureUrl: url } }.to_json + ) + end end diff --git a/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb b/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb index 69cd28074..9ff5dc193 100644 --- a/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb +++ b/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb @@ -850,6 +850,51 @@ describe Whatsapp::Providers::WhatsappBaileysService do end end + describe '#get_profile_pic' do + let(:test_jid) { '551187654321@s.whatsapp.net' } + + context 'when response is successful' do + it 'returns the profile picture URL data' do + stub_request(:get, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/profile-picture-url") + .with( + headers: stub_headers(whatsapp_channel), + query: { jid: test_jid } + ) + .to_return( + status: 200, + body: { data: { profilePictureUrl: 'https://pps.whatsapp.net/v/t61.24694-24/avatar.jpg', jid: test_jid } }.to_json + ) + + result = service.get_profile_pic(test_jid) + + expect(result).to eq({ + 'data' => { + 'profilePictureUrl' => 'https://pps.whatsapp.net/v/t61.24694-24/avatar.jpg', + 'jid' => test_jid + } + }) + end + end + + context 'when response fails' do + it 'returns nil when profile picture not found (404)' do + stub_request(:get, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/profile-picture-url") + .with( + headers: stub_headers(whatsapp_channel), + query: { jid: test_jid } + ) + .to_return( + status: 404, + body: { error: 'Profile picture not found' }.to_json + ) + + result = service.get_profile_pic(test_jid) + + expect(result).to be_nil + end + end + end + def stub_headers(channel) { 'Content-Type' => 'application/json',