diff --git a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue index 75684bb54..80360171b 100644 --- a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue +++ b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue @@ -5,6 +5,8 @@ import { useConfig } from 'dashboard/composables/useConfig'; import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents'; import { useAI } from 'dashboard/composables/useAI'; import { useAdmin } from 'dashboard/composables/useAdmin'; +import { useAlert } from 'dashboard/composables'; +import { useStore } from 'vuex'; // components import ReplyBox from './ReplyBox.vue'; @@ -53,6 +55,7 @@ export default { const isPopOutReplyBox = ref(false); const conversationPanelRef = ref(null); const { isEnterprise } = useConfig(); + const store = useStore(); const keyboardEvents = { Escape: { @@ -82,6 +85,7 @@ export default { fetchLabelSuggestions, conversationPanelRef, isAdmin, + store, }; }, data() { @@ -467,6 +471,15 @@ export default { onCloseBaileysLinkDeviceModal() { this.showBaileysLinkDeviceModal = false; }, + onSetupProviderConnection() { + this.store + .dispatch('inboxes/setupChannelProvider', this.inbox.id) + .catch(e => { + // eslint-disable-next-line no-console + console.error('Error setting up provider connection:', e); + useAlert('Failed to reconnect. Please try again or contact support.'); + }); + }, }, }; @@ -493,14 +506,18 @@ export default { 'CONVERSATION.INBOX.WHATSAPP_BAILEYS_PROVIDER_CONNECTION.NOT_CONNECTED_CONTACT_ADMIN' ) " - :has-action-button="isAdmin" + has-action-button :action-button-label=" - $t( - 'CONVERSATION.INBOX.WHATSAPP_BAILEYS_PROVIDER_CONNECTION.LINK_DEVICE' - ) + isAdmin + ? $t( + 'CONVERSATION.INBOX.WHATSAPP_BAILEYS_PROVIDER_CONNECTION.LINK_DEVICE' + ) + : '' + " + :action-button-icon="isAdmin ? '' : 'i-lucide-refresh-cw'" + @primary-action=" + isAdmin ? onOpenBaileysLinkDeviceModal() : onSetupProviderConnection() " - action-button-icon="" - @primary-action="onOpenBaileysLinkDeviceModal" /> e + # NOTE: Don't prevent destruction if disconnect fails + Rails.logger.error "Failed to disconnect channel provider: #{e.message}" end def received_messages(messages, conversation) diff --git a/app/services/whatsapp/providers/whatsapp_baileys_service.rb b/app/services/whatsapp/providers/whatsapp_baileys_service.rb index dbe76ec20..1d9c68f7f 100644 --- a/app/services/whatsapp/providers/whatsapp_baileys_service.rb +++ b/app/services/whatsapp/providers/whatsapp_baileys_service.rb @@ -2,7 +2,7 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer include BaileysHelper class MessageContentTypeNotSupported < StandardError; end - class MessageNotSentError < StandardError; end + class ProviderUnavailableError < StandardError; end DEFAULT_CLIENT_NAME = ENV.fetch('BAILEYS_PROVIDER_DEFAULT_CLIENT_NAME', nil) DEFAULT_URL = ENV.fetch('BAILEYS_PROVIDER_DEFAULT_URL', nil) @@ -21,7 +21,9 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer }.compact.to_json ) - process_response(response) + raise ProviderUnavailableError unless process_response(response) + + true end def disconnect_channel_provider @@ -30,7 +32,9 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer headers: api_headers ) - process_response(response) + raise ProviderUnavailableError unless process_response(response) + + true end def send_message(phone_number, message) @@ -89,7 +93,9 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer }.to_json ) - process_response(response) + raise ProviderUnavailableError unless process_response(response) + + true end def update_presence(status) @@ -107,7 +113,9 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer }.to_json ) - process_response(response) + raise ProviderUnavailableError unless process_response(response) + + true end def read_messages(phone_number, messages) @@ -127,7 +135,9 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer }.to_json ) - process_response(response) + raise ProviderUnavailableError unless process_response(response) + + true end def unread_message(phone_number, message) # rubocop:disable Metrics/MethodLength @@ -152,7 +162,9 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer }.to_json ) - process_response(response) + raise ProviderUnavailableError unless process_response(response) + + true end def received_messages(phone_number, messages) @@ -172,7 +184,9 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer }.to_json ) - process_response(response) + raise ProviderUnavailableError unless process_response(response) + + true end private @@ -230,7 +244,7 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer }.to_json ) - raise MessageNotSentError unless process_response(response) + raise ProviderUnavailableError unless process_response(response) update_external_created_at(response) response.parsed_response.dig('data', 'key', 'id') @@ -257,6 +271,10 @@ 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) + end + define_method(method_name) do |*args, &block| original_method.bind_call(self, *args, &block) rescue StandardError => e @@ -268,6 +286,17 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer def handle_channel_error whatsapp_channel.update_provider_connection!(connection: 'close') + + return if @handling_error + + @handling_error = true + begin + setup_channel_provider_without_error_handling + rescue StandardError => e + Rails.logger.error "Failed to reconnect channel after error: #{e.message}" + ensure + @handling_error = false + end end with_error_handling :setup_channel_provider, diff --git a/spec/models/channel/whatsapp_spec.rb b/spec/models/channel/whatsapp_spec.rb index 185250491..0c47bf2bd 100644 --- a/spec/models/channel/whatsapp_spec.rb +++ b/spec/models/channel/whatsapp_spec.rb @@ -311,6 +311,8 @@ RSpec.describe Channel::Whatsapp do it 'destroys the channel on failure to disconnect' do stub_request(:delete, disconnect_url).to_return(status: 404, body: 'error message') + # NOTE: On failure, `setup_channel_provider` is called, so we re-stub to avoid errors + stub_request(:post, disconnect_url).to_return(status: 200) channel.destroy! diff --git a/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb b/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb index 5a979286c..282cfdc4e 100644 --- a/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb +++ b/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb @@ -35,7 +35,7 @@ describe Whatsapp::Providers::WhatsappBaileysService do end context 'when response is unsuccessful' do - it 'logs the error and returns false' do + it 'raises ProviderUnavailableError and logs the error' do stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}") .with( headers: stub_headers(whatsapp_channel), @@ -51,12 +51,14 @@ describe Whatsapp::Providers::WhatsappBaileysService do body: 'error message', headers: {} ) - allow(Rails.logger).to receive(:error).with('error message') - response = service.setup_channel_provider + allow(Rails.logger).to receive(:error) - expect(response).to be(false) - expect(Rails.logger).to have_received(:error) + expect do + service.setup_channel_provider + end.to raise_error(Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError) + + expect(Rails.logger).to have_received(:error).with('error message').twice end end end @@ -75,7 +77,8 @@ describe Whatsapp::Providers::WhatsappBaileysService do end context 'when response is unsuccessful' do - it 'logs the error and returns false' do + it 'raises ProviderUnavailableError and logs the error' do + # Stub the failing request stub_request(:delete, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}") .with(headers: stub_headers(whatsapp_channel)) .to_return( @@ -83,12 +86,18 @@ describe Whatsapp::Providers::WhatsappBaileysService do body: 'error message', headers: {} ) - allow(Rails.logger).to receive(:error).with('error message') - response = service.disconnect_channel_provider + # Stub the reconnection attempt + stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}") + .to_return(status: 200) - expect(response).to be(false) - expect(Rails.logger).to have_received(:error) + allow(Rails.logger).to receive(:error) + + expect do + service.disconnect_channel_provider + end.to raise_error(Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError) + + expect(Rails.logger).to have_received(:error).with('error message') end end end @@ -325,7 +334,7 @@ describe Whatsapp::Providers::WhatsappBaileysService do end context 'when request is unsuccessful' do - it 'raises MessageNotSentError' do + it 'raises ProviderUnavailableError' do stub_request(:post, request_path) .to_return( status: 400, @@ -333,9 +342,13 @@ describe Whatsapp::Providers::WhatsappBaileysService do body: result_body.to_json ) + # Stub the reconnection attempt + stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}") + .to_return(status: 200) + expect do service.send_message(test_send_phone_number, message) - end.to raise_error(Whatsapp::Providers::WhatsappBaileysService::MessageNotSentError) + end.to raise_error(Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError) end end end @@ -376,26 +389,6 @@ describe Whatsapp::Providers::WhatsappBaileysService do expect(service.validate_provider_config?).to be(false) expect(Rails.logger).to have_received(:error) end - - context 'when provider responds with 5XX' do - it 'updated provider connection to close' do - whatsapp_channel.update!(provider_connection: { 'connection' => 'open' }) - allow(HTTParty).to receive(:post).with( - "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/send-message", - headers: stub_headers(whatsapp_channel), - body: { - jid: test_send_jid, - messageContent: { text: message.content } - }.to_json - ).and_raise(HTTParty::ResponseError.new(OpenStruct.new(status_code: 500))) - - expect do - service.send_message(test_send_phone_number, message) - end.to raise_error(HTTParty::ResponseError) - - expect(whatsapp_channel.provider_connection['connection']).to eq('close') - end - end end end @@ -411,6 +404,24 @@ describe Whatsapp::Providers::WhatsappBaileysService do expect(result).to be(true) end + + context 'when request is unsuccessful' do + it 'raises ProviderUnavailableError' do + stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/read-messages") + .with( + headers: stub_headers(whatsapp_channel), + body: { keys: [{ id: message.source_id, remoteJid: test_send_jid, fromMe: false }] }.to_json + ).to_return(status: 400, body: 'error message', headers: {}) + + # Stub the reconnection attempt + stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}") + .to_return(status: 200) + + expect do + service.read_messages(test_send_phone_number, [message]) + end.to raise_error(Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError) + end + end end describe '#unread_message' do @@ -436,6 +447,35 @@ describe Whatsapp::Providers::WhatsappBaileysService do expect(result).to be(true) end + + context 'when request is unsuccessful' do + it 'raises ProviderUnavailableError' do + stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/chat-modify") + .with( + headers: stub_headers(whatsapp_channel), + body: { + jid: test_send_jid, + mod: { + markRead: false, + lastMessages: [ + { + key: { id: 'msg_123', remoteJid: test_send_jid, fromMe: false }, + messageTimestamp: 123 + } + ] + } + }.to_json + ).to_return(status: 400, body: 'error message', headers: {}) + + # Stub the reconnection attempt + stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}") + .to_return(status: 200) + + expect do + service.unread_message(test_send_phone_number, message) + end.to raise_error(Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError) + end + end end describe '#received_messages' do @@ -452,6 +492,26 @@ describe Whatsapp::Providers::WhatsappBaileysService do expect(result).to be(true) end + + context 'when request is unsuccessful' do + it 'raises ProviderUnavailableError' do + stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/send-receipts") + .with( + headers: stub_headers(whatsapp_channel), + body: { + keys: [{ id: message.source_id, remoteJid: test_send_jid, fromMe: false }] + }.to_json + ).to_return(status: 400, body: 'error message', headers: {}) + + # Stub the reconnection attempt + stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}") + .to_return(status: 200) + + expect do + service.received_messages(test_send_phone_number, [message]) + end.to raise_error(Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError) + end + end end describe '#toggle_typing_status' do @@ -505,26 +565,33 @@ describe Whatsapp::Providers::WhatsappBaileysService do expect(request).to have_been_requested end - it 'logs the error and returns false' do - stub_request(:patch, request_path) - .with( - headers: stub_headers(whatsapp_channel), - body: { - toJid: test_send_jid, - type: 'composing' - }.to_json - ) - .to_return( - status: 400, - body: 'error message', - headers: {} - ) - allow(Rails.logger).to receive(:error).with('error message') + context 'when request is unsuccessful' do + it 'raises ProviderUnavailableError and logs the error' do + stub_request(:patch, request_path) + .with( + headers: stub_headers(whatsapp_channel), + body: { + toJid: test_send_jid, + type: 'composing' + }.to_json + ) + .to_return( + status: 400, + body: 'error message', + headers: {} + ) - response = service.toggle_typing_status(test_send_phone_number, Events::Types::CONVERSATION_TYPING_ON) + stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}") + .to_return(status: 200) - expect(response).to be(false) - expect(Rails.logger).to have_received(:error) + allow(Rails.logger).to receive(:error) + + expect do + service.toggle_typing_status(test_send_phone_number, Events::Types::CONVERSATION_TYPING_ON) + end.to raise_error(Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError) + + expect(Rails.logger).to have_received(:error).with('error message') + end end end @@ -546,25 +613,32 @@ describe Whatsapp::Providers::WhatsappBaileysService do expect(request).to have_been_requested end - it 'logs the error and returns false' do - stub_request(:patch, request_path) - .with( - headers: stub_headers(whatsapp_channel), - body: { - type: 'available' - }.to_json - ) - .to_return( - status: 400, - body: 'error message', - headers: {} - ) - allow(Rails.logger).to receive(:error).with('error message') + context 'when request is unsuccessful' do + it 'raises ProviderUnavailableError and logs the error' do + stub_request(:patch, request_path) + .with( + headers: stub_headers(whatsapp_channel), + body: { + type: 'available' + }.to_json + ) + .to_return( + status: 400, + body: 'error message', + headers: {} + ) - response = service.update_presence('online') + stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}") + .to_return(status: 200) - expect(response).to be(false) - expect(Rails.logger).to have_received(:error) + allow(Rails.logger).to receive(:error) + + expect do + service.update_presence('online') + end.to raise_error(Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError) + + expect(Rails.logger).to have_received(:error).with('error message') + end end end @@ -586,6 +660,138 @@ describe Whatsapp::Providers::WhatsappBaileysService do end end + describe 'error handling' do + describe '#handle_channel_error' do + it 'updates provider connection to close' do + whatsapp_channel.update!(provider_connection: { 'connection' => 'open' }) + + stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}") + .with( + headers: stub_headers(whatsapp_channel), + body: { + clientName: 'chatwoot-test', + webhookUrl: whatsapp_channel.inbox.callback_webhook_url, + webhookVerifyToken: whatsapp_channel.provider_config['webhook_verify_token'], + includeMedia: false + }.to_json + ) + .to_return(status: 200) + + service.send(:handle_channel_error) + + expect(whatsapp_channel.reload.provider_connection['connection']).to eq('close') + end + + it 'attempts to reconnect by calling setup_channel_provider' do + stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}") + .with( + headers: stub_headers(whatsapp_channel), + body: { + clientName: 'chatwoot-test', + webhookUrl: whatsapp_channel.inbox.callback_webhook_url, + webhookVerifyToken: whatsapp_channel.provider_config['webhook_verify_token'], + includeMedia: false + }.to_json + ) + .to_return(status: 200) + + service.send(:handle_channel_error) + + expect(WebMock).to have_requested(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}") + end + + it 'logs error and does not raise when reconnection fails' do + stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}") + .with( + headers: stub_headers(whatsapp_channel), + body: { + clientName: 'chatwoot-test', + webhookUrl: whatsapp_channel.inbox.callback_webhook_url, + webhookVerifyToken: whatsapp_channel.provider_config['webhook_verify_token'], + includeMedia: false + }.to_json + ) + .to_return(status: 400, body: 'reconnection failed') + + allow(Rails.logger).to receive(:error) + + expect { service.send(:handle_channel_error) }.not_to raise_error + + expect(Rails.logger).to have_received(:error).with(/Failed to reconnect channel after error/) + end + + it 'prevents infinite loop with @handling_error flag' do + service.instance_variable_set(:@handling_error, true) + + expect(HTTParty).not_to receive(:post) + + service.send(:handle_channel_error) + + expect(whatsapp_channel.reload.provider_connection['connection']).to eq('close') + end + end + + describe 'error handling wrapper' do + context 'when send_message fails' do + it 'calls handle_channel_error and re-raises the error' do + stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/send-message") + .to_return(status: 500, body: 'server error') + + stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}") + .to_return(status: 200) + + whatsapp_channel.update!(provider_connection: { 'connection' => 'open' }) + + expect do + service.send_message(test_send_phone_number, message) + end.to raise_error(Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError) + + expect(whatsapp_channel.reload.provider_connection['connection']).to eq('close') + + expect(WebMock).to have_requested(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}") + end + end + + context 'when setup_channel_provider fails' do + it 'calls handle_channel_error and re-raises the error' do + stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}") + .to_return(status: 500, body: 'server error') + + whatsapp_channel.update!(provider_connection: { 'connection' => 'open' }) + allow(Rails.logger).to receive(:error) + + expect do + service.setup_channel_provider + end.to raise_error(Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError) + + expect(whatsapp_channel.reload.provider_connection['connection']).to eq('close') + + expect(Rails.logger).to have_received(:error).with(/Failed to reconnect channel after error/) + end + end + + context 'when toggle_typing_status fails' do + it 'calls handle_channel_error and re-raises the error' do + stub_request(:patch, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/presence") + .to_return(status: 500, body: 'server error') + + stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}") + .to_return(status: 200) + + whatsapp_channel.update!(provider_connection: { 'connection' => 'open' }) + + expect do + service.toggle_typing_status(test_send_phone_number, Events::Types::CONVERSATION_TYPING_ON) + end.to raise_error(Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError) + + expect(whatsapp_channel.reload.provider_connection['connection']).to eq('close') + + expect(WebMock).to have_requested(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}") + end + end + end + end + def stub_headers(channel) { 'Content-Type' => 'application/json',