From 65cd0717e6807239daee8af4c283c4ed966c1cc0 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Thu, 10 Apr 2025 12:58:38 +0530 Subject: [PATCH 01/41] fix: Handle Instagram echo events (#11275) This PR fixes the issue with message creation when someone sends messages from the Instagram app instead of the Chatwoot dashboard. --- app/builders/messages/instagram/base_message_builder.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/builders/messages/instagram/base_message_builder.rb b/app/builders/messages/instagram/base_message_builder.rb index b8f892d97..767115bc6 100644 --- a/app/builders/messages/instagram/base_message_builder.rb +++ b/app/builders/messages/instagram/base_message_builder.rb @@ -94,11 +94,10 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil def build_message # Duplicate webhook events may be sent for the same message # when a user is connected to the Instagram account through both Messenger and Instagram login. + # There is chance for echo events to be sent for the same message. # Therefore, we need to check if the message already exists before creating it. return if message_already_exists? - return if @outgoing_echo - return if message_content.blank? && all_unsupported_files? @message = conversation.messages.create!(message_params) From 50344a282be5026770cdf5d3659f7a87d662c949 Mon Sep 17 00:00:00 2001 From: Pranav Date: Fri, 11 Apr 2025 13:02:16 +0530 Subject: [PATCH 02/41] fix: Return new Array instead of freezed object (#11283) Users who have not changed the order of the sidebar items were not able to change it as the object returned was created using Object.freeze To reproduce: - Create a new user account, open a conversation, try changing the order of the sidebar items. --- app/javascript/dashboard/composables/useUISettings.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/dashboard/composables/useUISettings.js b/app/javascript/dashboard/composables/useUISettings.js index f67f58ebc..418011e10 100644 --- a/app/javascript/dashboard/composables/useUISettings.js +++ b/app/javascript/dashboard/composables/useUISettings.js @@ -36,7 +36,7 @@ const useConversationSidebarItemsOrder = uiSettings => { const { conversation_sidebar_items_order: itemsOrder } = uiSettings.value; // If the sidebar order is not set, use the default order. if (!itemsOrder) { - return DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER; + return [...DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER]; } // Create a copy of itemsOrder to avoid mutating the original store object. const itemsOrderCopy = [...itemsOrder]; From bdcb080e402d19447b94cd31fca2bf8373ee8d46 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Fri, 11 Apr 2025 19:11:29 +0530 Subject: [PATCH 03/41] feat: Handle instagram test service (#11244) This PR will handle the Instagram test events. We are using the last created Instagram channel as the test channel since we don't have any other channels for testing purposes at the time of Meta approval. https://github.com/user-attachments/assets/98302b7a-d72c-4950-9660-861a5e08d55f --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Shivam Mishra --- app/jobs/webhooks/instagram_events_job.rb | 19 +++++ app/services/instagram/test_event_service.rb | 79 +++++++++++++++++++ .../instagram_message_create_event.rb | 30 +++++++ .../instagram/test_event_service_spec.rb | 71 +++++++++++++++++ 4 files changed, 199 insertions(+) create mode 100644 app/services/instagram/test_event_service.rb create mode 100644 spec/services/instagram/test_event_service_spec.rb diff --git a/app/jobs/webhooks/instagram_events_job.rb b/app/jobs/webhooks/instagram_events_job.rb index eb08f2ebb..3db8b6ba9 100644 --- a/app/jobs/webhooks/instagram_events_job.rb +++ b/app/jobs/webhooks/instagram_events_job.rb @@ -24,6 +24,11 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob private def process_single_entry(entry) + if test_event?(entry) + process_test_event(entry) + return + end + process_messages(entry) end @@ -46,6 +51,20 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob messaging[:message].present? && messaging[:message][:is_echo].present? end + def test_event?(entry) + entry[:changes].present? + end + + def process_test_event(entry) + messaging = extract_messaging_from_test_event(entry) + + Instagram::TestEventService.new(messaging).perform if messaging.present? + end + + def extract_messaging_from_test_event(entry) + entry[:changes].first&.dig(:value) if entry[:changes].present? + end + def instagram_id(messaging) if agent_message_via_echo?(messaging) messaging[:sender][:id] diff --git a/app/services/instagram/test_event_service.rb b/app/services/instagram/test_event_service.rb new file mode 100644 index 000000000..6fe242494 --- /dev/null +++ b/app/services/instagram/test_event_service.rb @@ -0,0 +1,79 @@ +class Instagram::TestEventService + def initialize(messaging) + @messaging = messaging + end + + def perform + Rails.logger.info("Processing Instagram test webhook event, #{@messaging}") + + return false unless test_webhook_event? + + create_test_text + end + + private + + def test_webhook_event? + @messaging[:sender][:id] == '12334' && @messaging[:recipient][:id] == '23245' + end + + def create_test_text + # As of now, we are using the last created instagram channel as the test channel, + # since we don't have any other channel for testing purpose at the time of meta approval + channel = Channel::Instagram.last + + @inbox = ::Inbox.find_by(channel: channel) + return unless @inbox + + @contact = create_test_contact + + @conversation ||= create_test_conversation(conversation_params) + + @message = @conversation.messages.create!(test_message_params) + end + + def create_test_contact + @contact_inbox = @inbox.contact_inboxes.where(source_id: @messaging[:sender][:id]).first + unless @contact_inbox + @contact_inbox ||= @inbox.channel.create_contact_inbox( + 'sender_username', 'sender_username' + ) + end + + @contact_inbox.contact + end + + def create_test_conversation(conversation_params) + Conversation.find_by(conversation_params) || build_conversation(conversation_params) + end + + def test_message_params + { + account_id: @conversation.account_id, + inbox_id: @conversation.inbox_id, + message_type: 'incoming', + source_id: @messaging[:message][:mid], + content: @messaging[:message][:text], + sender: @contact + } + end + + def build_conversation(conversation_params) + Conversation.create!( + conversation_params.merge( + contact_inbox_id: @contact_inbox.id + ) + ) + end + + def conversation_params + { + account_id: @inbox.account_id, + inbox_id: @inbox.id, + contact_id: @contact.id, + additional_attributes: { + type: 'instagram_direct_message' + } + } + end +end diff --git a/spec/factories/instagram/instagram_message_create_event.rb b/spec/factories/instagram/instagram_message_create_event.rb index 99572d620..66d5e8a81 100644 --- a/spec/factories/instagram/instagram_message_create_event.rb +++ b/spec/factories/instagram/instagram_message_create_event.rb @@ -361,4 +361,34 @@ FactoryBot.define do end initialize_with { attributes } end + + factory :instagram_test_event, class: Hash do + entry do + [ + { + 'id': '0', + 'time': '2021-09-08T06:34:04+0000', + 'changes': [ + { + 'field': 'messages', + 'value': { + 'sender': { + 'id': '12334' + }, + 'recipient': { + 'id': '23245' + }, + 'timestamp': '1527459824', + 'message': { + 'mid': 'random_mid', + 'text': 'random_text' + } + } + } + ] + } + ] + end + initialize_with { attributes } + end end diff --git a/spec/services/instagram/test_event_service_spec.rb b/spec/services/instagram/test_event_service_spec.rb new file mode 100644 index 000000000..a783197e6 --- /dev/null +++ b/spec/services/instagram/test_event_service_spec.rb @@ -0,0 +1,71 @@ +require 'rails_helper' + +describe Instagram::TestEventService do + let(:account) { create(:account) } + let(:instagram_channel) { create(:channel_instagram, account: account) } + let(:inbox) { create(:inbox, channel: instagram_channel, account: account) } + + describe '#perform' do + context 'when validating test webhook event' do + let(:test_messaging) do + { + 'sender': { + 'id': '12334' + }, + 'recipient': { + 'id': '23245' + }, + 'timestamp': '1527459824', + 'message': { + 'mid': 'random_mid', + 'text': 'random_text' + } + }.with_indifferent_access + end + + it 'creates test message for valid test webhook event' do + # Ensure inbox exists before test + inbox + + service = described_class.new(test_messaging) + + expect { service.perform }.to change(Message, :count).by(1) + + message = Message.last + expect(message.content).to eq('random_text') + expect(message.source_id).to eq('random_mid') + expect(message.message_type).to eq('incoming') + end + + it 'creates a contact with sender_username' do + # Ensure inbox exists before test + inbox + + service = described_class.new(test_messaging) + service.perform + + contact = Contact.last + expect(contact.name).to eq('sender_username') + end + + it 'returns false for non-test webhook events' do + invalid_messaging = test_messaging.deep_dup + invalid_messaging[:sender][:id] = 'different_id' + + service = described_class.new(invalid_messaging) + + expect(service.perform).to be(false) + end + + it 'returns nil when no Instagram channel exists' do + # Delete all inboxes and channels + Inbox.destroy_all + Channel::Instagram.destroy_all + + service = described_class.new(test_messaging) + + expect(service.perform).to be_nil + end + end + end +end From e0097ab102dd993936394313fdd920d0d9cdb920 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Sat, 12 Apr 2025 08:52:12 +0530 Subject: [PATCH 04/41] chore: Centralize outgoing message reply restrictions for all the channels (#11279) --- .../widgets/conversation/MessagesView.vue | 8 +- .../i18n/locale/en/conversation.json | 1 + app/models/channel/api.rb | 4 - app/models/channel/facebook_page.rb | 4 - app/models/channel/twilio_sms.rb | 4 - app/models/channel/whatsapp.rb | 4 - app/models/concerns/channelable.rb | 4 - app/models/conversation.rb | 27 +- .../conversations/message_window_service.rb | 64 ++ spec/models/channel/twilio_sms_spec.rb | 22 - spec/models/conversation_spec.rb | 131 +--- .../message_window_service_spec.rb | 627 ++++++++++++++++++ 12 files changed, 721 insertions(+), 179 deletions(-) create mode 100644 app/services/conversations/message_window_service.rb create mode 100644 spec/services/conversations/message_window_service_spec.rb diff --git a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue index 865122386..a2efc93db 100644 --- a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue +++ b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue @@ -218,8 +218,14 @@ export default { if (additionalAttributes) { const { agent_reply_time_window_message: agentReplyTimeWindowMessage, + agent_reply_time_window: agentReplyTimeWindow, } = additionalAttributes; - return agentReplyTimeWindowMessage; + return ( + agentReplyTimeWindowMessage || + this.$t('CONVERSATION.API_HOURS_WINDOW', { + hours: agentReplyTimeWindow, + }) + ); } return ''; } diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 6cfe9d082..3fa98ffe3 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -32,6 +32,7 @@ "LOADING_CONVERSATIONS": "Loading Conversations", "CANNOT_REPLY": "You cannot reply due to", "24_HOURS_WINDOW": "24 hour message window restriction", + "API_HOURS_WINDOW": "You can only reply to this conversation within {hours} hours", "NOT_ASSIGNED_TO_YOU": "This conversation is not assigned to you. Would you like to assign this conversation to yourself?", "ASSIGN_TO_ME": "Assign to me", "TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to", diff --git a/app/models/channel/api.rb b/app/models/channel/api.rb index a2bf9da48..17270e560 100644 --- a/app/models/channel/api.rb +++ b/app/models/channel/api.rb @@ -33,10 +33,6 @@ class Channel::Api < ApplicationRecord 'API' end - def messaging_window_enabled? - additional_attributes.present? && additional_attributes['agent_reply_time_window'].present? - end - private def ensure_valid_agent_reply_time_window diff --git a/app/models/channel/facebook_page.rb b/app/models/channel/facebook_page.rb index 1755889f8..1b6e151cb 100644 --- a/app/models/channel/facebook_page.rb +++ b/app/models/channel/facebook_page.rb @@ -32,10 +32,6 @@ class Channel::FacebookPage < ApplicationRecord 'Facebook' end - def messaging_window_enabled? - false - end - def create_contact_inbox(instagram_id, name) @contact_inbox = ::ContactInboxWithContactBuilder.new({ source_id: instagram_id, diff --git a/app/models/channel/twilio_sms.rb b/app/models/channel/twilio_sms.rb index 935ab2a14..8dbe47703 100644 --- a/app/models/channel/twilio_sms.rb +++ b/app/models/channel/twilio_sms.rb @@ -41,10 +41,6 @@ class Channel::TwilioSms < ApplicationRecord medium == 'sms' ? 'Twilio SMS' : 'Whatsapp' end - def messaging_window_enabled? - medium == 'whatsapp' - end - def send_message(to:, body:, media_url: nil) params = send_message_from.merge(to: to, body: body) params[:media_url] = media_url if media_url.present? diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb index f95c2c30a..1848e17a9 100644 --- a/app/models/channel/whatsapp.rb +++ b/app/models/channel/whatsapp.rb @@ -46,10 +46,6 @@ class Channel::Whatsapp < ApplicationRecord end end - def messaging_window_enabled? - true - end - def mark_message_templates_updated # rubocop:disable Rails/SkipsModelValidations update_column(:message_templates_last_updated, Time.zone.now) diff --git a/app/models/concerns/channelable.rb b/app/models/concerns/channelable.rb index 0b23283e1..e3e9eba94 100644 --- a/app/models/concerns/channelable.rb +++ b/app/models/concerns/channelable.rb @@ -7,10 +7,6 @@ module Channelable after_update :create_audit_log_entry end - def messaging_window_enabled? - false - end - def create_audit_log_entry; end end diff --git a/app/models/conversation.rb b/app/models/conversation.rb index 2a7a49223..887db286e 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -115,14 +115,7 @@ class Conversation < ApplicationRecord delegate :auto_resolve_duration, to: :account def can_reply? - channel = inbox&.channel - - return can_reply_on_instagram? if additional_attributes['type'] == 'instagram_direct_message' - - return true unless channel&.messaging_window_enabled? - - messaging_window = inbox.api? ? channel.additional_attributes['agent_reply_time_window'].to_i : 24 - last_message_in_messaging_window?(messaging_window) + Conversations::MessageWindowService.new(self).can_reply? end def language @@ -137,24 +130,6 @@ class Conversation < ApplicationRecord messages&.incoming&.last end - def last_message_in_messaging_window?(time) - return false if last_incoming_message.nil? - - Time.current < last_incoming_message.created_at + time.hours - end - - def can_reply_on_instagram? - global_config = GlobalConfig.get('ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT') - - return false if last_incoming_message.nil? - - if global_config['ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT'] - Time.current < last_incoming_message.created_at + 7.days - else - last_message_in_messaging_window?(24) - end - end - def toggle_status # FIXME: implement state machine with aasm self.status = open? ? :resolved : :open diff --git a/app/services/conversations/message_window_service.rb b/app/services/conversations/message_window_service.rb new file mode 100644 index 000000000..2394c1791 --- /dev/null +++ b/app/services/conversations/message_window_service.rb @@ -0,0 +1,64 @@ +class Conversations::MessageWindowService + MESSAGING_WINDOW_24_HOURS = 24.hours + MESSAGING_WINDOW_7_DAYS = 7.days + + def initialize(conversation) + @conversation = conversation + end + + def can_reply? + return true if messaging_window.blank? + + last_message_in_messaging_window?(messaging_window) + end + + private + + def messaging_window + case @conversation.inbox.channel_type + when 'Channel::Api' + api_messaging_window + when 'Channel::FacebookPage' + messenger_messaging_window + when 'Channel::Instagram' + instagram_messaging_window + when 'Channel::Whatsapp' + MESSAGING_WINDOW_24_HOURS + when 'Channel::TwilioSms' + twilio_messaging_window + end + end + + def last_message_in_messaging_window?(time) + return false if last_incoming_message.nil? + + Time.current < last_incoming_message.created_at + time + end + + def api_messaging_window + return if @conversation.inbox.channel.additional_attributes['agent_reply_time_window'].blank? + + @conversation.inbox.channel.additional_attributes['agent_reply_time_window'].to_i.hours + end + + # Check medium of the inbox to determine the messaging window + def twilio_messaging_window + @conversation.inbox.channel.medium == 'whatsapp' ? MESSAGING_WINDOW_24_HOURS : nil + end + + def messenger_messaging_window + meta_messaging_window('ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT') + end + + def instagram_messaging_window + meta_messaging_window('ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT') + end + + def meta_messaging_window(config_key) + GlobalConfigService.load(config_key, nil) ? MESSAGING_WINDOW_7_DAYS : MESSAGING_WINDOW_24_HOURS + end + + def last_incoming_message + @last_incoming_message ||= @conversation.messages&.incoming&.last + end +end diff --git a/spec/models/channel/twilio_sms_spec.rb b/spec/models/channel/twilio_sms_spec.rb index 5f26eb706..ac89a0f7f 100644 --- a/spec/models/channel/twilio_sms_spec.rb +++ b/spec/models/channel/twilio_sms_spec.rb @@ -3,28 +3,6 @@ require 'rails_helper' RSpec.describe Channel::TwilioSms do - describe '#has_24_hour_messaging_window?' do - context 'with medium whatsapp' do - let!(:whatsapp_channel) { create(:channel_twilio_sms, medium: :whatsapp) } - - it 'returns true' do - expect(whatsapp_channel.messaging_window_enabled?).to be true - expect(whatsapp_channel.name).to eq 'Whatsapp' - expect(whatsapp_channel.medium).to eq 'whatsapp' - end - end - - context 'with medium sms' do - let!(:sms_channel) { create(:channel_twilio_sms, medium: :sms) } - - it 'returns false' do - expect(sms_channel.messaging_window_enabled?).to be false - expect(sms_channel.name).to eq 'Twilio SMS' - expect(sms_channel.medium).to eq 'sms' - end - end - end - describe '#validations' do context 'with phone number blank' do let!(:sms_channel) { create(:channel_twilio_sms, medium: :sms, phone_number: nil) } diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb index 1938fb204..e437f5d89 100644 --- a/spec/models/conversation_spec.rb +++ b/spec/models/conversation_spec.rb @@ -585,116 +585,6 @@ RSpec.describe Conversation do end end - describe '#can_reply?' do - describe 'on channels without 24 hour restriction' do - let(:conversation) { create(:conversation) } - - it 'returns true' do - expect(conversation.can_reply?).to be true - end - - it 'return true for facebook channels' do - stub_request(:post, /graph.facebook.com/) - facebook_channel = create(:channel_facebook_page) - facebook_inbox = create(:inbox, channel: facebook_channel, account: facebook_channel.account) - fb_conversation = create(:conversation, inbox: facebook_inbox, account: facebook_channel.account) - - expect(fb_conversation.can_reply?).to be true - expect(facebook_channel.messaging_window_enabled?).to be false - end - end - - describe 'on channels with 24 hour restriction' do - before do - stub_request(:post, /graph.facebook.com/) - end - - let!(:facebook_channel) { create(:channel_facebook_page) } - let!(:facebook_inbox) { create(:inbox, channel: facebook_channel, account: facebook_channel.account) } - let!(:conversation) { create(:conversation, inbox: facebook_inbox, account: facebook_channel.account) } - - context 'when instagram channel' do - it 'return true with HUMAN_AGENT if it is outside of 24 hour window' do - InstallationConfig.where(name: 'ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT').first_or_create(value: true) - - conversation.update(additional_attributes: { type: 'instagram_direct_message' }) - create( - :message, - account: conversation.account, - inbox: facebook_inbox, - conversation: conversation, - created_at: 48.hours.ago - ) - - expect(conversation.can_reply?).to be true - end - - it 'return false without HUMAN_AGENT if it is outside of 24 hour window' do - InstallationConfig.where(name: 'ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT').first_or_create(value: false) - - conversation.update(additional_attributes: { type: 'instagram_direct_message' }) - create( - :message, - account: conversation.account, - inbox: facebook_inbox, - conversation: conversation, - created_at: 48.hours.ago - ) - - expect(conversation.can_reply?).to be false - end - end - end - - describe 'on API channels' do - let!(:api_channel) { create(:channel_api, additional_attributes: {}) } - let!(:api_channel_with_limit) { create(:channel_api, additional_attributes: { agent_reply_time_window: '12' }) } - - context 'when agent_reply_time_window is not configured' do - it 'return true irrespective of the last message time' do - conversation = create(:conversation, inbox: api_channel.inbox) - create( - :message, - account: conversation.account, - inbox: api_channel.inbox, - conversation: conversation, - created_at: 13.hours.ago - ) - - expect(api_channel.additional_attributes['agent_reply_time_window']).to be_nil - expect(conversation.can_reply?).to be true - end - end - - context 'when agent_reply_time_window is configured' do - it 'return false if it is outside of agent_reply_time_window' do - conversation = create(:conversation, inbox: api_channel_with_limit.inbox) - create( - :message, - account: conversation.account, - inbox: api_channel_with_limit.inbox, - conversation: conversation, - created_at: 13.hours.ago - ) - - expect(api_channel_with_limit.additional_attributes['agent_reply_time_window']).to eq '12' - expect(conversation.can_reply?).to be false - end - - it 'return true if it is inside of agent_reply_time_window' do - conversation = create(:conversation, inbox: api_channel_with_limit.inbox) - create( - :message, - account: conversation.account, - inbox: api_channel_with_limit.inbox, - conversation: conversation - ) - expect(conversation.can_reply?).to be true - end - end - end - end - describe '#delete conversation' do include ActiveJob::TestHelper @@ -918,4 +808,25 @@ RSpec.describe Conversation do end end end + + describe '#can_reply?' do + let(:conversation) { create(:conversation) } + let(:message_window_service) { instance_double(Conversations::MessageWindowService) } + + before do + allow(Conversations::MessageWindowService).to receive(:new).with(conversation).and_return(message_window_service) + end + + it 'delegates to MessageWindowService' do + allow(message_window_service).to receive(:can_reply?).and_return(true) + expect(conversation.can_reply?).to be true + expect(message_window_service).to have_received(:can_reply?) + end + + it 'returns false when MessageWindowService returns false' do + allow(message_window_service).to receive(:can_reply?).and_return(false) + expect(conversation.can_reply?).to be false + expect(message_window_service).to have_received(:can_reply?) + end + end end diff --git a/spec/services/conversations/message_window_service_spec.rb b/spec/services/conversations/message_window_service_spec.rb new file mode 100644 index 000000000..34c5ebbad --- /dev/null +++ b/spec/services/conversations/message_window_service_spec.rb @@ -0,0 +1,627 @@ +require 'rails_helper' + +RSpec.describe Conversations::MessageWindowService do + describe 'on API channels' do + let!(:api_channel) { create(:channel_api, additional_attributes: {}) } + let!(:api_channel_with_limit) { create(:channel_api, additional_attributes: { agent_reply_time_window: '12' }) } + + context 'when agent_reply_time_window is not configured' do + it 'return true irrespective of the last message time' do + conversation = create(:conversation, inbox: api_channel.inbox) + create( + :message, + account: conversation.account, + inbox: api_channel.inbox, + conversation: conversation, + created_at: 13.hours.ago + ) + service = described_class.new(conversation) + + expect(api_channel.additional_attributes['agent_reply_time_window']).to be_nil + expect(service.can_reply?).to be true + end + end + + context 'when agent_reply_time_window is configured' do + it 'return false if it is outside of agent_reply_time_window' do + conversation = create(:conversation, inbox: api_channel_with_limit.inbox) + create( + :message, + account: conversation.account, + inbox: api_channel_with_limit.inbox, + conversation: conversation, + created_at: 13.hours.ago + ) + service = described_class.new(conversation) + + expect(api_channel_with_limit.additional_attributes['agent_reply_time_window']).to eq '12' + expect(service.can_reply?).to be false + end + + it 'return true if it is inside of agent_reply_time_window' do + conversation = create(:conversation, inbox: api_channel_with_limit.inbox) + create( + :message, + account: conversation.account, + inbox: api_channel_with_limit.inbox, + conversation: conversation + ) + service = described_class.new(conversation) + + expect(service.can_reply?).to be true + end + end + end + + describe 'on Facebook channels' do + before do + stub_request(:post, /graph.facebook.com/) + end + + let!(:facebook_channel) { create(:channel_facebook_page) } + let!(:facebook_inbox) { create(:inbox, channel: facebook_channel, account: facebook_channel.account) } + let!(:conversation) { create(:conversation, inbox: facebook_inbox, account: facebook_channel.account) } + + context 'when the HUMAN_AGENT is enabled' do + it 'return false if the last message is outgoing' do + with_modified_env ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT: 'true' do + service = described_class.new(conversation) + expect(service.can_reply?).to be false + end + end + + it 'return true if the last message is incoming and within the messaging window (with in 24 hours)' do + with_modified_env ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT: 'true' do + create( + :message, + account: conversation.account, + inbox: facebook_inbox, + conversation: conversation, + created_at: 13.hours.ago + ) + service = described_class.new(conversation) + expect(service.can_reply?).to be true + end + end + + it 'return true if the last message is incoming and within the messaging window (with in 7 days)' do + with_modified_env ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT: 'true' do + create( + :message, + account: conversation.account, + inbox: facebook_inbox, + conversation: conversation, + created_at: 5.days.ago + ) + service = described_class.new(conversation) + expect(service.can_reply?).to be true + end + end + + it 'return false if the last message is incoming and outside the messaging window (8 days ago )' do + with_modified_env ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT: 'true' do + create( + :message, + account: conversation.account, + inbox: facebook_inbox, + conversation: conversation, + created_at: 8.days.ago + ) + service = described_class.new(conversation) + expect(service.can_reply?).to be false + end + end + + it 'return true if last message is outgoing but previous incoming message is within window' do + with_modified_env ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT: 'true' do + create( + :message, + account: conversation.account, + inbox: facebook_inbox, + conversation: conversation, + message_type: :incoming, + created_at: 6.hours.ago + ) + + create( + :message, + account: conversation.account, + inbox: facebook_inbox, + conversation: conversation, + message_type: :outgoing, + created_at: 1.hour.ago + ) + + service = described_class.new(conversation) + expect(service.can_reply?).to be true + end + end + + it 'considers only the last incoming message for determining time window' do + with_modified_env ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT: 'true' do + # Old message outside window + create( + :message, + account: conversation.account, + inbox: facebook_inbox, + conversation: conversation, + created_at: 10.days.ago + ) + + # Recent message within window + create( + :message, + account: conversation.account, + inbox: facebook_inbox, + conversation: conversation, + created_at: 6.hours.ago + ) + + service = described_class.new(conversation) + expect(service.can_reply?).to be true + end + end + end + + context 'when the HUMAN_AGENT is disabled' do + with_modified_env ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT: 'false' do + it 'return false if the last message is outgoing' do + service = described_class.new(conversation) + expect(service.can_reply?).to be false + end + + it 'return false if the last message is incoming and outside the messaging window ( 8 days ago )' do + create( + :message, + account: conversation.account, + inbox: facebook_inbox, + conversation: conversation, + created_at: 4.days.ago + ) + service = described_class.new(conversation) + expect(service.can_reply?).to be false + end + + it 'return true if the last message is incoming and within the messaging window (24 hours limit)' do + create( + :message, + account: conversation.account, + inbox: facebook_inbox, + conversation: conversation, + created_at: 13.hours.ago + ) + service = described_class.new(conversation) + expect(service.can_reply?).to be true + end + end + end + end + + describe 'on Instagram channels' do + let!(:instagram_channel) { create(:channel_instagram) } + let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: instagram_channel.account) } + let!(:conversation) { create(:conversation, inbox: instagram_inbox, account: instagram_channel.account) } + + context 'when the HUMAN_AGENT is enabled' do + it 'return false if the last message is outgoing' do + with_modified_env ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT: 'true' do + service = described_class.new(conversation) + expect(service.can_reply?).to be false + end + end + + it 'return true if the last message is incoming and within the messaging window (with in 24 hours)' do + with_modified_env ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT: 'true' do + create( + :message, + account: conversation.account, + inbox: instagram_inbox, + conversation: conversation, + created_at: 13.hours.ago + ) + service = described_class.new(conversation) + expect(service.can_reply?).to be true + end + end + + it 'return true if the last message is incoming and within the messaging window (with in 7 days)' do + with_modified_env ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT: 'true' do + create( + :message, + account: conversation.account, + inbox: instagram_inbox, + conversation: conversation, + created_at: 6.days.ago + ) + service = described_class.new(conversation) + expect(service.can_reply?).to be true + end + end + + it 'return false if the last message is incoming and outside the messaging window (8 days ago)' do + with_modified_env ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT: 'true' do + create( + :message, + account: conversation.account, + inbox: instagram_inbox, + conversation: conversation, + created_at: 8.days.ago + ) + service = described_class.new(conversation) + expect(service.can_reply?).to be false + end + end + + it 'return true if last message is outgoing but previous incoming message is within window' do + with_modified_env ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT: 'true' do + create( + :message, + account: conversation.account, + inbox: instagram_inbox, + conversation: conversation, + message_type: :incoming, + created_at: 6.hours.ago + ) + + create( + :message, + account: conversation.account, + inbox: instagram_inbox, + conversation: conversation, + message_type: :outgoing, + created_at: 1.hour.ago + ) + + service = described_class.new(conversation) + expect(service.can_reply?).to be true + end + end + + it 'considers only the last incoming message for determining time window' do + with_modified_env ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT: 'true' do + # Old message outside window + create( + :message, + account: conversation.account, + inbox: instagram_inbox, + conversation: conversation, + created_at: 10.days.ago + ) + + # Recent message within window + create( + :message, + account: conversation.account, + inbox: instagram_inbox, + conversation: conversation, + created_at: 6.hours.ago + ) + + service = described_class.new(conversation) + expect(service.can_reply?).to be true + end + end + + it 'return true if the last message is incoming and exactly at the edge of 24-hour window with HUMAN_AGENT disabled' do + with_modified_env ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT: 'true' do + create( + :message, + account: conversation.account, + inbox: instagram_inbox, + conversation: conversation, + created_at: 24.hours.ago + ) + service = described_class.new(conversation) + expect(service.can_reply?).to be true + end + end + end + + context 'when the HUMAN_AGENT is disabled' do + it 'return false if the last message is outgoing' do + with_modified_env ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT: 'false' do + service = described_class.new(conversation) + expect(service.can_reply?).to be false + end + end + + it 'return false if the last message is incoming and outside the messaging window (8 days ago)' do + with_modified_env ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT: 'false' do + create( + :message, + account: conversation.account, + inbox: instagram_inbox, + conversation: conversation, + created_at: 9.days.ago + ) + service = described_class.new(conversation) + expect(service.can_reply?).to be false + end + end + + it 'return true if the last message is incoming and within the messaging window (24 hours limit)' do + with_modified_env ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT: 'false' do + create( + :message, + account: conversation.account, + inbox: instagram_inbox, + conversation: conversation, + created_at: 13.hours.ago + ) + service = described_class.new(conversation) + expect(service.can_reply?).to be true + end + end + end + end + + describe 'on WhatsApp Cloud channels' do + let!(:whatsapp_channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false) } + let!(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: whatsapp_channel.account) } + let!(:conversation) { create(:conversation, inbox: whatsapp_inbox, account: whatsapp_channel.account) } + + it 'return false if the last message is outgoing' do + service = described_class.new(conversation) + expect(service.can_reply?).to be false + end + + it 'return true if the last message is incoming and within the messaging window (with in 24 hours)' do + create( + :message, + account: conversation.account, + inbox: whatsapp_inbox, + conversation: conversation, + created_at: 13.hours.ago + ) + service = described_class.new(conversation) + expect(service.can_reply?).to be true + end + + it 'return false if the last message is incoming and outside the messaging window (24 hours limit)' do + create( + :message, + account: conversation.account, + inbox: whatsapp_inbox, + conversation: conversation, + created_at: 25.hours.ago + ) + service = described_class.new(conversation) + expect(service.can_reply?).to be false + end + + it 'return true if last message is outgoing but previous incoming message is within window' do + create( + :message, + account: conversation.account, + inbox: whatsapp_inbox, + conversation: conversation, + message_type: :incoming, + created_at: 6.hours.ago + ) + + create( + :message, + account: conversation.account, + inbox: whatsapp_inbox, + conversation: conversation, + message_type: :outgoing, + created_at: 1.hour.ago + ) + + service = described_class.new(conversation) + expect(service.can_reply?).to be true + end + + it 'considers only the last incoming message for determining time window' do + # Old message outside window + create( + :message, + account: conversation.account, + inbox: whatsapp_inbox, + conversation: conversation, + created_at: 10.days.ago + ) + + # Recent message within window + create( + :message, + account: conversation.account, + inbox: whatsapp_inbox, + conversation: conversation, + created_at: 6.hours.ago + ) + + service = described_class.new(conversation) + expect(service.can_reply?).to be true + end + end + + describe 'on Web widget channels' do + let!(:widget_channel) { create(:channel_widget) } + let!(:widget_inbox) { create(:inbox, channel: widget_channel, account: widget_channel.account) } + let!(:conversation) { create(:conversation, inbox: widget_inbox, account: widget_channel.account) } + + it 'return true irrespective of the last message time' do + create( + :message, + account: conversation.account, + inbox: widget_inbox, + conversation: conversation, + created_at: 13.hours.ago + ) + service = described_class.new(conversation) + expect(service.can_reply?).to be true + end + end + + describe 'on SMS channels' do + let!(:sms_channel) { create(:channel_sms) } + let!(:sms_inbox) { create(:inbox, channel: sms_channel, account: sms_channel.account) } + let!(:conversation) { create(:conversation, inbox: sms_inbox, account: sms_channel.account) } + + it 'return true irrespective of the last message time' do + create( + :message, + account: conversation.account, + inbox: sms_inbox, + conversation: conversation, + created_at: 13.hours.ago + ) + service = described_class.new(conversation) + expect(service.can_reply?).to be true + end + end + + describe 'on Telegram channels' do + let!(:telegram_channel) { create(:channel_telegram) } + let!(:telegram_inbox) { create(:inbox, channel: telegram_channel, account: telegram_channel.account) } + let!(:conversation) { create(:conversation, inbox: telegram_inbox, account: telegram_channel.account) } + + it 'return true irrespective of the last message time' do + create( + :message, + account: conversation.account, + inbox: telegram_inbox, + conversation: conversation, + created_at: 13.hours.ago + ) + service = described_class.new(conversation) + expect(service.can_reply?).to be true + end + end + + describe 'on Email channels' do + let!(:email_channel) { create(:channel_email) } + let!(:email_inbox) { create(:inbox, channel: email_channel, account: email_channel.account) } + let!(:conversation) { create(:conversation, inbox: email_inbox, account: email_channel.account) } + + it 'return true irrespective of the last message time' do + create( + :message, + account: conversation.account, + inbox: email_inbox, + conversation: conversation, + created_at: 13.hours.ago + ) + service = described_class.new(conversation) + expect(service.can_reply?).to be true + end + end + + describe 'on Line channels' do + let!(:line_channel) { create(:channel_line) } + let!(:line_inbox) { create(:inbox, channel: line_channel, account: line_channel.account) } + let!(:conversation) { create(:conversation, inbox: line_inbox, account: line_channel.account) } + + it 'return true irrespective of the last message time' do + create( + :message, + account: conversation.account, + inbox: line_inbox, + conversation: conversation, + created_at: 13.hours.ago + ) + service = described_class.new(conversation) + expect(service.can_reply?).to be true + end + end + + describe 'on Twilio SMS channels' do + let!(:twilio_sms_channel) { create(:channel_twilio_sms) } + let!(:twilio_sms_inbox) { create(:inbox, channel: twilio_sms_channel, account: twilio_sms_channel.account) } + let!(:conversation) { create(:conversation, inbox: twilio_sms_inbox, account: twilio_sms_channel.account) } + + it 'return true irrespective of the last message time' do + create( + :message, + account: conversation.account, + inbox: twilio_sms_inbox, + conversation: conversation, + created_at: 13.hours.ago + ) + service = described_class.new(conversation) + expect(service.can_reply?).to be true + end + end + + describe 'on WhatsApp Twilio channels' do + let!(:whatsapp_channel) { create(:channel_twilio_sms, medium: :whatsapp) } + let!(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: whatsapp_channel.account) } + let!(:conversation) { create(:conversation, inbox: whatsapp_inbox, account: whatsapp_channel.account) } + + it 'return false if the last message is outgoing' do + service = described_class.new(conversation) + expect(service.can_reply?).to be false + end + + it 'return true if the last message is incoming and within the messaging window (with in 24 hours)' do + create( + :message, + account: conversation.account, + inbox: whatsapp_inbox, + conversation: conversation, + created_at: 13.hours.ago + ) + service = described_class.new(conversation) + expect(service.can_reply?).to be true + end + + it 'return false if the last message is incoming and outside the messaging window (24 hours limit)' do + create( + :message, + account: conversation.account, + inbox: whatsapp_inbox, + conversation: conversation, + created_at: 25.hours.ago + ) + service = described_class.new(conversation) + expect(service.can_reply?).to be false + end + + it 'return true if last message is outgoing but previous incoming message is within window' do + create( + :message, + account: conversation.account, + inbox: whatsapp_inbox, + conversation: conversation, + message_type: :incoming, + created_at: 6.hours.ago + ) + + create( + :message, + account: conversation.account, + inbox: whatsapp_inbox, + conversation: conversation, + message_type: :outgoing, + created_at: 1.hour.ago + ) + + service = described_class.new(conversation) + expect(service.can_reply?).to be true + end + + it 'considers only the last incoming message for determining time window' do + # Old message outside window + create( + :message, + account: conversation.account, + inbox: whatsapp_inbox, + conversation: conversation, + created_at: 10.days.ago + ) + + # Recent message within window + create( + :message, + account: conversation.account, + inbox: whatsapp_inbox, + conversation: conversation, + created_at: 6.hours.ago + ) + + service = described_class.new(conversation) + expect(service.can_reply?).to be true + end + end +end From befdfb0ae6624543b21cf6dc99d300fff92d6995 Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Sat, 12 Apr 2025 10:52:49 +0530 Subject: [PATCH 05/41] fix: use stricter validation to restrict gmail signups (#11285) - use stricter validation to restrict gmail signups --- .../omniauth_callbacks_controller.rb | 2 +- .../omniauth_callbacks_controller_spec.rb | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb index e1cf76d6b..2b3ea9067 100644 --- a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb +++ b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb @@ -55,7 +55,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa def validate_business_account? # return true if the user is a business account, false if it is a gmail account - auth_hash['info']['email'].exclude?('@gmail.com') + auth_hash['info']['email'].downcase.exclude?('@gmail.com') end def create_account_for_user diff --git a/spec/controllers/devise/omniauth_callbacks_controller_spec.rb b/spec/controllers/devise/omniauth_callbacks_controller_spec.rb index b6c5cd781..3513b9c34 100644 --- a/spec/controllers/devise/omniauth_callbacks_controller_spec.rb +++ b/spec/controllers/devise/omniauth_callbacks_controller_spec.rb @@ -56,6 +56,23 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do end end + it 'blocks personal accounts signup with different Gmail case variations' do + with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do + # Test different case variations of Gmail + ['personal@Gmail.com', 'personal@GMAIL.com', 'personal@Gmail.COM'].each do |email| + set_omniauth_config(email) + get '/omniauth/google_oauth2/callback' + + # expect a 302 redirect to auth/google_oauth2/callback + expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback') + follow_redirect! + + # expect a 302 redirect to app/login with error disallowing personal accounts + expect(response).to redirect_to(%r{/app/login\?error=business-account-only$}) + end + end + end + # This test does not affect line coverage, but it is important to ensure that the logic # does not allow any signup if the ENV explicitly disables it it 'blocks signup if ENV disabled' do From a1f61f0e21778c7d1954335ff19b820bfbdf36d5 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Mon, 14 Apr 2025 15:56:16 +0530 Subject: [PATCH 06/41] fix: Hide message status for failed and deleted messages (#11294) # Pull Request Template ## Description This PR fixes the issue where a clock with animation is shown inside the message bubble for failed and deleted messages. The message status is now hidden for such messages. ## Type of change Please delete options that are not relevant. - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? ### Before **Failed message bubble** image **Deleted message bubble** image ### After **Failed message bubble** image **Deleted message bubble** image ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- .../components-next/message/MessageMeta.vue | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/app/javascript/dashboard/components-next/message/MessageMeta.vue b/app/javascript/dashboard/components-next/message/MessageMeta.vue index d2ddd2c77..8d7c22dab 100644 --- a/app/javascript/dashboard/components-next/message/MessageMeta.vue +++ b/app/javascript/dashboard/components-next/message/MessageMeta.vue @@ -22,8 +22,14 @@ const { isAInstagramChannel, } = useInbox(); -const { status, isPrivate, createdAt, sourceId, messageType } = - useMessageContext(); +const { + status, + isPrivate, + createdAt, + sourceId, + messageType, + contentAttributes, +} = useMessageContext(); const readableTime = computed(() => messageTimestamp(createdAt.value, 'LLL d, h:mm a') @@ -31,6 +37,11 @@ const readableTime = computed(() => const showStatusIndicator = computed(() => { if (isPrivate.value) return false; + // Don't show status for failed messages, we already show error message + if (status.value === MESSAGE_STATUS.FAILED) return false; + // Don't show status for deleted messages + if (contentAttributes.value?.deleted) return false; + if (messageType.value === MESSAGE_TYPES.OUTGOING) return true; if (messageType.value === MESSAGE_TYPES.TEMPLATE) return true; From 78a40114ef42344b05d8d93f053098b0367b628f Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Mon, 14 Apr 2025 19:59:56 -0700 Subject: [PATCH 07/41] feat: Use portal logo as favicon in helpcenter pages (#11289) - Added favicon link to portal layout when logo is present - Added tests to verify favicon behavior with and without logo --- app/views/layouts/portal.html.erb | 4 +++ .../public/api/v1/portals_controller_spec.rb | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/app/views/layouts/portal.html.erb b/app/views/layouts/portal.html.erb index 5d8983621..c4135fd37 100644 --- a/app/views/layouts/portal.html.erb +++ b/app/views/layouts/portal.html.erb @@ -39,6 +39,10 @@ By default, it renders: <% else %> <%= @portal.page_title%> <% end %> + + <% if @portal.logo.present? %> + + <% end %> <% unless @theme_from_params.blank? %> <%# this adds the theme from params, ensuring that there a localstorage value set %> diff --git a/spec/controllers/public/api/v1/portals_controller_spec.rb b/spec/controllers/public/api/v1/portals_controller_spec.rb index 004d013ea..92ab56e5b 100644 --- a/spec/controllers/public/api/v1/portals_controller_spec.rb +++ b/spec/controllers/public/api/v1/portals_controller_spec.rb @@ -30,6 +30,32 @@ RSpec.describe Public::Api::V1::PortalsController, type: :request do expect(json_response['error']).to eql "Domain: www.example.com is not registered with us. \ Please send us an email at support@chatwoot.com with the custom domain name and account API key" end + + context 'when portal has a logo' do + it 'includes the logo as favicon' do + # Attach a test image to the portal + file = Rails.root.join('spec/assets/sample.png').open + portal.logo.attach(io: file, filename: 'sample.png', content_type: 'image/png') + file.close + + get "/hc/#{portal.slug}/en" + + expect(response).to have_http_status(:success) + expect(response.body).to include(' Date: Tue, 15 Apr 2025 13:20:06 +0530 Subject: [PATCH 08/41] fix: Display error message on empty response from Captain (#11302) --- .../copilot/CopilotAssistantMessage.vue | 12 +++++++++++- .../dashboard/i18n/locale/en/integrations.json | 1 + 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/javascript/dashboard/components-next/copilot/CopilotAssistantMessage.vue b/app/javascript/dashboard/components-next/copilot/CopilotAssistantMessage.vue index 0fefd76ee..1877e1630 100644 --- a/app/javascript/dashboard/components-next/copilot/CopilotAssistantMessage.vue +++ b/app/javascript/dashboard/components-next/copilot/CopilotAssistantMessage.vue @@ -33,6 +33,8 @@ const insertIntoRichEditor = computed(() => { ); }); +const hasEmptyMessageContent = computed(() => !props.message?.content); + const useCopilotResponse = () => { if (insertIntoRichEditor.value) { emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content); @@ -53,9 +55,17 @@ const useCopilotResponse = () => { />
{{ $t('CAPTAIN.NAME') }}
-
+ + {{ $t('CAPTAIN.COPILOT.EMPTY_MESSAGE') }} + +