feat: allow non-admin to refresh baileys connection, and automatically setup connection provider on error (#89)

* feat: allow non-admin to refresh baileys connection, and automatically setup connection provider on error

* Update app/javascript/dashboard/components/widgets/conversation/MessagesView.vue

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* chore: ignore console lint

* feat: handle disconnect errors gracefully and update tests accordingly

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Gabriel Jablonski 2025-07-28 20:20:32 -03:00 committed by GitHub
parent adee31a383
commit 11f8aac294
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 341 additions and 84 deletions

View File

@ -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.');
});
},
},
};
</script>
@ -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"
/>
</template>
<Banner

View File

@ -254,7 +254,7 @@
"INBOX": {
"WHATSAPP_BAILEYS_PROVIDER_CONNECTION": {
"NOT_CONNECTED": "WhatsApp is not connected. Please link your device again.",
"NOT_CONNECTED_CONTACT_ADMIN": "WhatsApp is not connected. Please contact your administrator to link your device again.",
"NOT_CONNECTED_CONTACT_ADMIN": "WhatsApp is not connected. Click this button to try to reconnect, or please contact your administrator to link your device again.",
"LINK_DEVICE": "Link device"
}
}

View File

@ -254,7 +254,7 @@
"INBOX": {
"WHATSAPP_BAILEYS_PROVIDER_CONNECTION": {
"NOT_CONNECTED": "O WhatsApp não está conectado. Por favor conecte o seu dispositivo novamente.",
"NOT_CONNECTED_CONTACT_ADMIN": "O WhatsApp não está conectado. Por favor contate o seu administrador para conectar o dispositivo novamente.",
"NOT_CONNECTED_CONTACT_ADMIN": "O WhatsApp não está conectado. Clique no botão ao lado para tentar reconectar, ou contate o seu administrador para conectar o dispositivo novamente.",
"LINK_DEVICE": "Conectar dispositivo"
}
}

View File

@ -111,6 +111,9 @@ class Channel::Whatsapp < ApplicationRecord
def disconnect_channel_provider
provider_service.disconnect_channel_provider
rescue StandardError => 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)

View File

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

View File

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

View File

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