diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index c7608399e..5a82039a9 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -29,6 +29,9 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder Rails.logger.warn("Facebook authentication error for inbox: #{@inbox.id} with error: #{e.message}") Rails.logger.error e @inbox.channel.authorization_error! + rescue CustomExceptions::ConversationMessageCreationLocked => e + Rails.logger.info("[FacebookMessageBuilder] Dropped message for inbox #{@inbox.id}: #{e.message}") + true rescue StandardError => e ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception true diff --git a/app/builders/messages/instagram/base_message_builder.rb b/app/builders/messages/instagram/base_message_builder.rb index 8045e84c9..2bf134536 100644 --- a/app/builders/messages/instagram/base_message_builder.rb +++ b/app/builders/messages/instagram/base_message_builder.rb @@ -14,6 +14,8 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil ActiveRecord::Base.transaction do build_message end + rescue CustomExceptions::ConversationMessageCreationLocked => e + Rails.logger.info("[InstagramMessageBuilder] Dropped message for inbox #{@inbox.id}: #{e.message}") rescue StandardError => e handle_error(e) end diff --git a/app/controllers/api/v1/accounts/conversations/messages_controller.rb b/app/controllers/api/v1/accounts/conversations/messages_controller.rb index b632ac78d..a23826feb 100644 --- a/app/controllers/api/v1/accounts/conversations/messages_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/messages_controller.rb @@ -9,6 +9,8 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts:: user = Current.user || @resource mb = Messages::MessageBuilder.new(user, @conversation, params) @message = mb.perform + rescue CustomExceptions::ConversationMessageCreationLocked => e + render_error_response(e) rescue StandardError => e render_could_not_create_error(e.message) end @@ -27,11 +29,14 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts:: def retry return if message.blank? + raise CustomExceptions::ConversationMessageCreationLocked, @conversation if @conversation.message_creation_locked? service = Messages::StatusUpdateService.new(message, 'sent') service.perform message.update!(content_attributes: {}) ::SendReplyJob.perform_later(message.id) + rescue CustomExceptions::ConversationMessageCreationLocked => e + render_error_response(e) rescue StandardError => e render_could_not_create_error(e.message) end diff --git a/app/controllers/api/v1/webhooks_controller.rb b/app/controllers/api/v1/webhooks_controller.rb index 4f9f6d55b..b3e7364bc 100644 --- a/app/controllers/api/v1/webhooks_controller.rb +++ b/app/controllers/api/v1/webhooks_controller.rb @@ -9,6 +9,9 @@ class Api::V1::WebhooksController < ApplicationController def twitter_events twitter_consumer.consume head :ok + rescue CustomExceptions::ConversationMessageCreationLocked => e + Rails.logger.info("Skipping Twitter webhook because message creation is locked (#{e.message})") + head :ok rescue StandardError => e ChatwootExceptionTracker.new(e).capture_exception head :ok diff --git a/app/controllers/concerns/request_exception_handler.rb b/app/controllers/concerns/request_exception_handler.rb index ccab0090a..c4c29a0ac 100644 --- a/app/controllers/concerns/request_exception_handler.rb +++ b/app/controllers/concerns/request_exception_handler.rb @@ -3,6 +3,7 @@ module RequestExceptionHandler included do rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid + rescue_from CustomExceptions::ConversationMessageCreationLocked, with: :render_error_response end private diff --git a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue index e4745595e..3fef4f763 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue @@ -125,6 +125,10 @@ export default { type: Boolean, default: false, }, + isMessageCreationLocked: { + type: Boolean, + default: false, + }, }, emits: [ 'toggleInsertArticle', @@ -250,7 +254,16 @@ export default { : this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP'); }, enableInsertArticleInReply() { - return this.portalSlug; + return !this.isMessageCreationLocked && this.portalSlug; + }, + showQuotedReplyButton() { + return this.showQuotedReplyToggle && !this.isMessageCreationLocked; + }, + showWhatsAppTemplateButton() { + return this.enableWhatsAppTemplates && !this.isMessageCreationLocked; + }, + showContentTemplateButton() { + return this.enableContentTemplates && !this.isMessageCreationLocked; }, isFetchingAppIntegrations() { return this.uiFlags.isFetching; @@ -340,7 +353,7 @@ export default { @click="toggleMessageSignature" /> { }; const onSend = () => { + if (props.isMessageCreationLocked) return; + emit('send', copilotEditorContent.value); copilotEditorContent.value = ''; }; @@ -58,7 +64,9 @@ const onSend = () => { @after-enter="emit('contentReady')" >
+ { this.sendMessage( @@ -888,6 +904,8 @@ export default { editorMessage = '', copilotAcceptedMessage = '' ) { + if (this.isMessageCreationLocked) return; + try { await this.$store.dispatch( 'createPendingMessageAndSend', @@ -907,6 +925,8 @@ export default { } }, async onSendWhatsAppReply(messagePayload) { + if (this.isMessageCreationLocked) return; + this.sendMessage({ conversationId: this.currentChat.id, ...messagePayload, @@ -914,6 +934,8 @@ export default { this.hideWhatsappTemplatesModal(); }, async onSendContentTemplateReply(messagePayload) { + if (this.isMessageCreationLocked) return; + this.sendMessage({ conversationId: this.currentChat.id, ...messagePayload, @@ -921,6 +943,8 @@ export default { this.hideContentTemplatesModal(); }, setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) { + if (this.isMessageCreationLocked) return; + // Clear attachments when switching between private note and reply modes // This is to prevent from breaking the upload rules if (this.attachedFiles.length > 0) this.attachedFiles = []; @@ -943,6 +967,8 @@ export default { this.onFocus(); }, executeCopilotAction(action, data) { + if (this.isMessageCreationLocked) return; + this.copilot.execute(action, data); }, clearMessage() { @@ -1235,6 +1261,8 @@ export default { this.$nextTick(() => this.messageEditor?.focusEditorInputField()); }, onSubmitCopilotReply() { + if (this.isMessageCreationLocked) return; + const acceptedMessage = this.copilot.accept(); this.message = acceptedMessage; this.setCopilotAcceptedMessage(acceptedMessage); @@ -1315,6 +1343,7 @@ export default { v-if="copilot.isActive.value && !showAudioRecorderEditor" :show-copilot-editor="copilot.showEditor.value" :is-generating-content="copilot.isGenerating.value" + :is-message-creation-locked="isMessageCreationLocked" :generated-content="copilot.generatedContent.value" :placeholder="$t('CONVERSATION.FOOTER.COPILOT_MSG_INPUT')" @focus="onFocus" @@ -1395,7 +1424,9 @@ export default { @@ -1412,6 +1443,7 @@ export default { :is-send-disabled="isReplyButtonDisabled" :is-note="isPrivate" :is-editor-disabled="isEditorDisabled" + :is-message-creation-locked="isMessageCreationLocked" :on-file-upload="onFileUpload" :on-send="onSendReply" :conversation-type="conversationType" diff --git a/app/javascript/dashboard/components/widgets/conversation/specs/messageCreationLock.spec.js b/app/javascript/dashboard/components/widgets/conversation/specs/messageCreationLock.spec.js new file mode 100644 index 000000000..c752ded5a --- /dev/null +++ b/app/javascript/dashboard/components/widgets/conversation/specs/messageCreationLock.spec.js @@ -0,0 +1,149 @@ +import { shallowMount } from '@vue/test-utils'; + +import CopilotEditorSection from '../CopilotEditorSection.vue'; +import MessagesView from '../MessagesView.vue'; +import ReplyBox from '../ReplyBox.vue'; +import ReplyBottomPanel from '../../WootWriter/ReplyBottomPanel.vue'; + +const mountCopilotEditorSection = props => + shallowMount(CopilotEditorSection, { + props: { + showCopilotEditor: true, + isGeneratingContent: false, + generatedContent: 'Suggested reply', + ...props, + }, + global: { + stubs: { + Transition: false, + CopilotEditor: { + template: + '