+ {
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:
+ '',
+ },
+ CaptainLoader: true,
+ },
+ },
+ });
+
+describe('conversation message creation lock UI', () => {
+ describe('MessagesView', () => {
+ it('returns the message-limit banner copy with the configured limit', () => {
+ const $t = vi.fn((key, params) => `${key}:${params.limit}`);
+ const message =
+ MessagesView.computed.messageCreationLockBannerMessage.call({
+ currentChat: {
+ message_creation_locked: true,
+ message_creation_lock_reason: 'message_limit',
+ message_limit: 10000,
+ },
+ $t,
+ });
+
+ expect(message).toBe(
+ 'CONVERSATION.MESSAGE_CREATION_LOCK.MESSAGE_LIMIT:10000'
+ );
+ expect($t).toHaveBeenCalledWith(
+ 'CONVERSATION.MESSAGE_CREATION_LOCK.MESSAGE_LIMIT',
+ { limit: 10000 }
+ );
+ });
+
+ it('returns the manual lock banner copy', () => {
+ const $t = vi.fn(key => key);
+ const message =
+ MessagesView.computed.messageCreationLockBannerMessage.call({
+ currentChat: {
+ message_creation_locked: true,
+ message_creation_lock_reason: 'manual',
+ },
+ $t,
+ });
+
+ expect(message).toBe('CONVERSATION.MESSAGE_CREATION_LOCK.MANUAL');
+ });
+ });
+
+ describe('ReplyBox', () => {
+ it('disables the editor when message creation is locked', () => {
+ const isDisabled = ReplyBox.computed.isEditorDisabled.call({
+ isMessageCreationLocked: true,
+ isAWhatsAppChannel: false,
+ isAPIInbox: false,
+ isOnPrivateNote: false,
+ currentChat: { can_reply: true },
+ });
+
+ expect(isDisabled).toBe(true);
+ });
+
+ it('uses the locked placeholder when message creation is locked', () => {
+ const placeholder = ReplyBox.computed.messagePlaceHolder.call({
+ isEditorDisabled: true,
+ isMessageCreationLocked: true,
+ $t: key => key,
+ });
+
+ expect(placeholder).toBe('CONVERSATION.FOOTER.MESSAGE_CREATION_LOCKED');
+ });
+ });
+
+ describe('ReplyBottomPanel', () => {
+ it('keeps template actions available when only the editor is disabled', () => {
+ const showWhatsAppTemplateButton =
+ ReplyBottomPanel.computed.showWhatsAppTemplateButton.call({
+ enableWhatsAppTemplates: true,
+ isMessageCreationLocked: false,
+ });
+ const showContentTemplateButton =
+ ReplyBottomPanel.computed.showContentTemplateButton.call({
+ enableContentTemplates: true,
+ isMessageCreationLocked: false,
+ });
+
+ expect(showWhatsAppTemplateButton).toBe(true);
+ expect(showContentTemplateButton).toBe(true);
+ });
+
+ it('hides template actions when message creation is locked', () => {
+ const showWhatsAppTemplateButton =
+ ReplyBottomPanel.computed.showWhatsAppTemplateButton.call({
+ enableWhatsAppTemplates: true,
+ isMessageCreationLocked: true,
+ });
+ const showContentTemplateButton =
+ ReplyBottomPanel.computed.showContentTemplateButton.call({
+ enableContentTemplates: true,
+ isMessageCreationLocked: true,
+ });
+
+ expect(showWhatsAppTemplateButton).toBe(false);
+ expect(showContentTemplateButton).toBe(false);
+ });
+ });
+
+ describe('CopilotEditorSection', () => {
+ it('keeps the follow-up editor available when message creation is unlocked', async () => {
+ const wrapper = mountCopilotEditorSection({
+ isMessageCreationLocked: false,
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find('[data-testid="copilot-editor"]').exists()).toBe(
+ true
+ );
+ });
+
+ it('hides the follow-up editor when message creation is locked', async () => {
+ const wrapper = mountCopilotEditorSection({
+ isMessageCreationLocked: true,
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find('[data-testid="copilot-editor"]').exists()).toBe(
+ false
+ );
+ });
+ });
+});
diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json
index c34ed44de..f27908db4 100644
--- a/app/javascript/dashboard/i18n/locale/en/conversation.json
+++ b/app/javascript/dashboard/i18n/locale/en/conversation.json
@@ -44,6 +44,10 @@
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You won’t be able to send messages from this conversation anymore.",
+ "MESSAGE_CREATION_LOCK": {
+ "MESSAGE_LIMIT": "This conversation has reached the {limit} message limit. We will drop all messages after this limit.",
+ "MANUAL": "This conversation is locked. We will drop all new messages until it is unlocked."
+ },
"REPLYING_TO": "You are replying to:",
"REMOVE_SELECTION": "Remove Selection",
"DOWNLOAD": "Download",
@@ -213,6 +217,7 @@
"MESSAGING_RESTRICTED": "You cannot reply to this conversation",
"MESSAGING_RESTRICTED_WHATSAPP": "You can only reply using a template message due to 24-hour message window restriction",
"MESSAGING_RESTRICTED_API": "You can only reply using a template message due to message window restriction",
+ "MESSAGE_CREATION_LOCKED": "Message creation is locked for this conversation",
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
"COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else... Press enter to send follow-up",
"CLICK_HERE": "Click here to update",
diff --git a/app/javascript/dashboard/store/modules/conversations/actions.js b/app/javascript/dashboard/store/modules/conversations/actions.js
index 72ab8fa5e..81134666b 100644
--- a/app/javascript/dashboard/store/modules/conversations/actions.js
+++ b/app/javascript/dashboard/store/modules/conversations/actions.js
@@ -289,12 +289,14 @@ const actions = {
sendMessageWithData: async ({ commit }, pendingMessage) => {
const { conversation_id: conversationId, id } = pendingMessage;
+ const shouldRetryMessage =
+ hasMessageFailedWithExternalError(pendingMessage);
try {
commit(types.ADD_MESSAGE, {
...pendingMessage,
status: MESSAGE_STATUS.PROGRESS,
});
- const response = hasMessageFailedWithExternalError(pendingMessage)
+ const response = shouldRetryMessage
? await MessageApi.retry(conversationId, id)
: await MessageApi.create(pendingMessage);
commit(types.ADD_MESSAGE, {
@@ -309,6 +311,31 @@ const actions = {
const errorMessage = error.response
? error.response.data.error
: undefined;
+ const errorResponse = error.response?.data || {};
+ if (errorResponse.message_creation_locked) {
+ if (shouldRetryMessage) {
+ commit(types.ADD_MESSAGE, {
+ ...pendingMessage,
+ meta: {
+ ...(pendingMessage.meta || {}),
+ error: errorResponse.error,
+ },
+ status: MESSAGE_STATUS.FAILED,
+ });
+ } else {
+ commit(types.DELETE_MESSAGE, pendingMessage);
+ }
+ commit(types.SET_CONVERSATION_MESSAGE_CREATION_LOCK, {
+ conversationId,
+ message_limit: errorResponse.message_limit,
+ message_limit_reached: errorResponse.message_limit_reached,
+ message_creation_locked: errorResponse.message_creation_locked,
+ message_creation_lock_reason:
+ errorResponse.message_creation_lock_reason,
+ });
+ throw error;
+ }
+
commit(types.ADD_MESSAGE, {
...pendingMessage,
meta: {
@@ -322,6 +349,12 @@ const actions = {
addMessage({ commit, rootGetters }, message) {
commit(types.ADD_MESSAGE, message);
+ if (message.conversation) {
+ commit(types.SET_CONVERSATION_MESSAGE_CREATION_LOCK, {
+ conversationId: message.conversation_id,
+ ...message.conversation,
+ });
+ }
if (message.message_type === MESSAGE_TYPE.INCOMING) {
commit(types.SET_CONVERSATION_CAN_REPLY, {
conversationId: message.conversation_id,
diff --git a/app/javascript/dashboard/store/modules/conversations/index.js b/app/javascript/dashboard/store/modules/conversations/index.js
index 8a13940c0..e89e60c62 100644
--- a/app/javascript/dashboard/store/modules/conversations/index.js
+++ b/app/javascript/dashboard/store/modules/conversations/index.js
@@ -29,6 +29,24 @@ const getConversationById = _state => conversationId => {
return _state.allConversations.find(c => c.id === conversationId);
};
+const MESSAGE_CREATION_LOCK_FIELDS = [
+ 'message_limit',
+ 'message_limit_reached',
+ 'message_creation_locked',
+ 'message_creation_lock_reason',
+];
+
+const updateConversationMessageCreationLock = (
+ conversation,
+ lockState = {}
+) => {
+ MESSAGE_CREATION_LOCK_FIELDS.forEach(field => {
+ if (Object.prototype.hasOwnProperty.call(lockState, field)) {
+ conversation[field] = lockState[field];
+ }
+ });
+};
+
// mutations
export const mutations = {
[types.SET_ALL_CONVERSATION](_state, conversationList) {
@@ -205,6 +223,20 @@ export const mutations = {
});
},
+ [types.DELETE_MESSAGE]({ allConversations }, message) {
+ const { conversation_id: conversationId } = message;
+ const [chat] = getSelectedChatConversation({
+ allConversations,
+ selectedChatId: conversationId,
+ });
+ if (!chat) return;
+
+ const pendingMessageIndex = findPendingMessageIndex(chat, message);
+ if (pendingMessageIndex !== -1) {
+ chat.messages.splice(pendingMessageIndex, 1);
+ }
+ },
+
[types.ADD_MESSAGE]({ allConversations, selectedChatId }, message) {
const { conversation_id: conversationId } = message;
const [chat] = getSelectedChatConversation({
@@ -216,17 +248,29 @@ export const mutations = {
const pendingMessageIndex = findPendingMessageIndex(chat, message);
if (pendingMessageIndex !== -1) {
chat.messages[pendingMessageIndex] = message;
+ updateConversationMessageCreationLock(chat, message.conversation);
} else {
chat.messages.push(message);
chat.timestamp = message.created_at;
const { conversation: { unread_count: unreadCount = 0 } = {} } = message;
chat.unread_count = unreadCount;
+ updateConversationMessageCreationLock(chat, message.conversation);
if (selectedChatId === conversationId) {
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
}
}
},
+ [types.SET_CONVERSATION_MESSAGE_CREATION_LOCK](
+ _state,
+ { conversationId, ...lockState }
+ ) {
+ const chat = getConversationById(_state)(conversationId);
+ if (chat) {
+ updateConversationMessageCreationLock(chat, lockState);
+ }
+ },
+
[types.ADD_CONVERSATION](_state, conversation) {
const exists = _state.allConversations.some(c => c.id === conversation.id);
if (!exists) {
diff --git a/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js
index fa052ec1b..9e303b034 100644
--- a/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js
@@ -288,6 +288,138 @@ describe('#actions', () => {
actions.addMessage({ commit }, message);
expect(commit.mock.calls).toEqual([[types.ADD_MESSAGE, message]]);
});
+
+ it('syncs message creation lock metadata from message payload', () => {
+ const message = {
+ id: 1,
+ message_type: 1,
+ conversation_id: 1,
+ conversation: {
+ message_limit: 10000,
+ message_limit_reached: true,
+ message_creation_locked: true,
+ message_creation_lock_reason: 'message_limit',
+ },
+ };
+ actions.addMessage({ commit }, message);
+ expect(commit.mock.calls).toEqual([
+ [types.ADD_MESSAGE, message],
+ [
+ types.SET_CONVERSATION_MESSAGE_CREATION_LOCK,
+ {
+ conversationId: 1,
+ ...message.conversation,
+ },
+ ],
+ ]);
+ });
+ });
+
+ describe('#sendMessageWithData', () => {
+ it('removes optimistic message and updates lock metadata when creation is locked', async () => {
+ const localCommit = vi.fn();
+ const pendingMessage = {
+ id: 'temp-1',
+ conversation_id: 1,
+ message: 'Locked message',
+ };
+ const error = {
+ response: {
+ data: {
+ error: 'This conversation has reached the 10000 message limit.',
+ message_limit: 10000,
+ message_limit_reached: true,
+ message_creation_locked: true,
+ message_creation_lock_reason: 'message_limit',
+ },
+ },
+ };
+ axios.mockRejectedValue(error);
+
+ await expect(
+ actions.sendMessageWithData({ commit: localCommit }, pendingMessage)
+ ).rejects.toBe(error);
+
+ expect(localCommit.mock.calls).toEqual([
+ [
+ types.ADD_MESSAGE,
+ {
+ ...pendingMessage,
+ status: 'progress',
+ },
+ ],
+ [types.DELETE_MESSAGE, pendingMessage],
+ [
+ types.SET_CONVERSATION_MESSAGE_CREATION_LOCK,
+ {
+ conversationId: 1,
+ message_limit: 10000,
+ message_limit_reached: true,
+ message_creation_locked: true,
+ message_creation_lock_reason: 'message_limit',
+ },
+ ],
+ ]);
+ });
+
+ it('keeps an existing failed message when retry is locked', async () => {
+ const localCommit = vi.fn();
+ const pendingMessage = {
+ id: 42,
+ conversation_id: 1,
+ message: 'Retry message',
+ status: 'failed',
+ content_attributes: {
+ external_error: 'Provider rejected the message',
+ },
+ };
+ const error = {
+ response: {
+ data: {
+ error: 'This conversation has reached the 10000 message limit.',
+ message_limit: 10000,
+ message_limit_reached: true,
+ message_creation_locked: true,
+ message_creation_lock_reason: 'message_limit',
+ },
+ },
+ };
+ axios.post.mockRejectedValue(error);
+
+ await expect(
+ actions.sendMessageWithData({ commit: localCommit }, pendingMessage)
+ ).rejects.toBe(error);
+
+ expect(localCommit.mock.calls).toEqual([
+ [
+ types.ADD_MESSAGE,
+ {
+ ...pendingMessage,
+ status: 'progress',
+ },
+ ],
+ [
+ types.ADD_MESSAGE,
+ {
+ ...pendingMessage,
+ meta: {
+ error: error.response.data.error,
+ },
+ status: 'failed',
+ },
+ ],
+ [
+ types.SET_CONVERSATION_MESSAGE_CREATION_LOCK,
+ {
+ conversationId: 1,
+ message_limit: 10000,
+ message_limit_reached: true,
+ message_creation_locked: true,
+ message_creation_lock_reason: 'message_limit',
+ },
+ ],
+ ]);
+ });
});
describe('#markMessagesRead', () => {
diff --git a/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js
index fc1c61b35..cef046002 100644
--- a/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js
@@ -109,6 +109,46 @@ describe('#mutations', () => {
});
});
+ describe('#SET_CONVERSATION_MESSAGE_CREATION_LOCK', () => {
+ it('sets message creation lock metadata', () => {
+ const state = { allConversations: [{ id: 1, messages: [] }] };
+ mutations[types.SET_CONVERSATION_MESSAGE_CREATION_LOCK](state, {
+ conversationId: 1,
+ message_limit: 10000,
+ message_limit_reached: true,
+ message_creation_locked: true,
+ message_creation_lock_reason: 'message_limit',
+ });
+
+ expect(state.allConversations[0]).toMatchObject({
+ message_limit: 10000,
+ message_limit_reached: true,
+ message_creation_locked: true,
+ message_creation_lock_reason: 'message_limit',
+ });
+ });
+ });
+
+ describe('#DELETE_MESSAGE', () => {
+ it('removes a pending message from the conversation', () => {
+ const state = {
+ allConversations: [
+ {
+ id: 1,
+ messages: [{ id: 'temp-1', echo_id: 'temp-1' }],
+ },
+ ],
+ };
+
+ mutations[types.DELETE_MESSAGE](state, {
+ conversation_id: 1,
+ id: 'temp-1',
+ });
+
+ expect(state.allConversations[0].messages).toEqual([]);
+ });
+ });
+
describe('#ADD_MESSAGE', () => {
it('does not add message to the store if conversation does not exist', () => {
const state = { allConversations: [] };
@@ -208,6 +248,32 @@ describe('#mutations', () => {
]);
expect(emitter.emit).not.toHaveBeenCalled();
});
+
+ it('updates conversation lock metadata from message payload', () => {
+ const state = {
+ allConversations: [{ id: 1, messages: [] }],
+ selectedChatId: 1,
+ };
+
+ mutations[types.ADD_MESSAGE](state, {
+ conversation_id: 1,
+ content: 'Test message',
+ created_at: 1602256198,
+ conversation: {
+ message_limit: 10000,
+ message_limit_reached: true,
+ message_creation_locked: true,
+ message_creation_lock_reason: 'message_limit',
+ },
+ });
+
+ expect(state.allConversations[0]).toMatchObject({
+ message_limit: 10000,
+ message_limit_reached: true,
+ message_creation_locked: true,
+ message_creation_lock_reason: 'message_limit',
+ });
+ });
});
describe('#CHANGE_CONVERSATION_STATUS', () => {
diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js
index 059d9636e..92ec108ea 100644
--- a/app/javascript/dashboard/store/mutation-types.js
+++ b/app/javascript/dashboard/store/mutation-types.js
@@ -54,6 +54,8 @@ export default {
UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY',
UPDATE_MESSAGE_CALL_STATUS: 'UPDATE_MESSAGE_CALL_STATUS',
SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES',
+ SET_CONVERSATION_MESSAGE_CREATION_LOCK:
+ 'SET_CONVERSATION_MESSAGE_CREATION_LOCK',
SET_ALL_ATTACHMENTS: 'SET_ALL_ATTACHMENTS',
ADD_CONVERSATION_ATTACHMENTS: 'ADD_CONVERSATION_ATTACHMENTS',
diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb
index 22be5f036..58ecc42bb 100644
--- a/app/jobs/application_job.rb
+++ b/app/jobs/application_job.rb
@@ -5,4 +5,8 @@ class ApplicationJob < ActiveJob::Base
job.instance_variable_get(:@serialized_arguments)
} because of ActiveJob::DeserializationError (#{error.message})")
end
+
+ discard_on CustomExceptions::ConversationMessageCreationLocked do |job, error|
+ Rails.logger.info("Skipping #{job.class} because message creation is locked (#{error.message})")
+ end
end
diff --git a/app/jobs/inboxes/fetch_imap_emails_job.rb b/app/jobs/inboxes/fetch_imap_emails_job.rb
index e2c48488b..5f8ed77c2 100644
--- a/app/jobs/inboxes/fetch_imap_emails_job.rb
+++ b/app/jobs/inboxes/fetch_imap_emails_job.rb
@@ -69,6 +69,8 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
rescue Timeout::Error
mark_email_as_failed(inbound_mail.message_id)
Rails.logger.error "[IMAP] Email processing timeout (#{email_processing_timeout}s): #{inbound_mail.message_id}"
+ rescue CustomExceptions::ConversationMessageCreationLocked => e
+ log_message_creation_locked(inbound_mail, e)
rescue StandardError => e
mark_email_as_failed(inbound_mail.message_id)
Rails.logger.error "[IMAP] Failed to process email #{inbound_mail.message_id}: #{e.message}"
@@ -79,4 +81,8 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
def email_processing_timeout
GlobalConfigService.load('EMAIL_PROCESSING_TIMEOUT_SECONDS', 60).to_i
end
+
+ def log_message_creation_locked(inbound_mail, error)
+ Rails.logger.info "[IMAP] Dropped email #{inbound_mail.message_id}: #{error.message}"
+ end
end
diff --git a/app/mailboxes/application_mailbox.rb b/app/mailboxes/application_mailbox.rb
index a77f5ea43..9e571dfc5 100644
--- a/app/mailboxes/application_mailbox.rb
+++ b/app/mailboxes/application_mailbox.rb
@@ -1,6 +1,10 @@
class ApplicationMailbox < ActionMailbox::Base
include MailboxHelper
+ rescue_from CustomExceptions::ConversationMessageCreationLocked do |error|
+ Rails.logger.info("Skipping inbound email because message creation is locked (#{error.message})")
+ end
+
# Last part is the regex for the UUID
# Eg: email should be something like : reply+6bdc3f4d-0bec-4515-a284-5d916fdde489@domain.com
REPLY_EMAIL_UUID_PATTERN = /^reply\+([0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12})$/i
diff --git a/app/models/conversation.rb b/app/models/conversation.rb
index d9c06c4d8..6f23f9db9 100644
--- a/app/models/conversation.rb
+++ b/app/models/conversation.rb
@@ -52,6 +52,10 @@
#
class Conversation < ApplicationRecord
+ MESSAGE_CREATION_LOCK_KEY = 'message_creation_lock'.freeze
+ MESSAGE_CREATION_LOCK_REASON_MANUAL = 'manual'.freeze
+ MESSAGE_CREATION_LOCK_REASON_MESSAGE_LIMIT = 'message_limit'.freeze
+
include Labelable
include LlmFormattable
include AssignmentHandler
@@ -133,6 +137,58 @@ class Conversation < ApplicationRecord
Conversations::MessageWindowService.new(self).can_reply?
end
+ def message_limit
+ Limits.conversation_message_limit
+ end
+
+ def message_limit_reached?
+ messages.where(account_id: account_id).reorder(nil).limit(message_limit).count >= message_limit
+ end
+
+ def manual_message_creation_locked?
+ message_creation_lock_data['locked'] == true
+ end
+
+ def message_creation_locked?
+ manual_message_creation_locked? || message_limit_reached?
+ end
+
+ def message_creation_lock_reason
+ message_creation_lock_reason_for(message_limit_reached?, manual_message_creation_locked?)
+ end
+
+ def message_creation_lock_state
+ limit_reached = message_limit_reached?
+ manually_locked = manual_message_creation_locked?
+
+ {
+ message_limit: message_limit,
+ message_limit_reached: limit_reached,
+ message_creation_locked: manually_locked || limit_reached,
+ message_creation_lock_reason: message_creation_lock_reason_for(limit_reached, manually_locked)
+ }
+ end
+
+ def message_creation_lock_data
+ additional_attributes&.fetch(MESSAGE_CREATION_LOCK_KEY, {}) || {}
+ end
+
+ def lock_message_creation!(reason: nil)
+ lock_data = {
+ 'locked' => true,
+ 'reason' => reason.presence || MESSAGE_CREATION_LOCK_REASON_MANUAL,
+ 'locked_at' => Time.current.iso8601
+ }
+
+ updated_additional_attributes = (additional_attributes || {}).merge(MESSAGE_CREATION_LOCK_KEY => lock_data)
+ update!(additional_attributes: updated_additional_attributes)
+ end
+
+ def unlock_message_creation!
+ updated_additional_attributes = (additional_attributes || {}).except(MESSAGE_CREATION_LOCK_KEY)
+ update!(additional_attributes: updated_additional_attributes)
+ end
+
def language
additional_attributes&.dig('conversation_language')
end
@@ -243,6 +299,11 @@ class Conversation < ApplicationRecord
private
+ def message_creation_lock_reason_for(limit_reached, manually_locked)
+ return MESSAGE_CREATION_LOCK_REASON_MESSAGE_LIMIT if limit_reached
+ return MESSAGE_CREATION_LOCK_REASON_MANUAL if manually_locked
+ end
+
def execute_after_update_commit_callbacks
handle_resolved_status_change
notify_status_change
@@ -315,7 +376,10 @@ class Conversation < ApplicationRecord
def allowed_keys?
(
previous_changes.keys.intersect?(list_of_keys) ||
- (previous_changes['additional_attributes'].present? && previous_changes['additional_attributes'][1].keys.intersect?(%w[conversation_language]))
+ (
+ previous_changes['additional_attributes'].present? &&
+ previous_changes['additional_attributes'][1].keys.intersect?(%w[conversation_language message_creation_lock])
+ )
)
end
diff --git a/app/models/message.rb b/app/models/message.rb
index f25d2e112..12fd824d6 100644
--- a/app/models/message.rb
+++ b/app/models/message.rb
@@ -63,6 +63,7 @@ class Message < ApplicationRecord
}.to_json.freeze
before_validation :ensure_content_type
+ before_validation :ensure_message_creation_unlocked, on: :create
before_validation :prevent_message_flooding
before_save :ensure_processed_message_content
before_save :ensure_in_reply_to
@@ -160,6 +161,7 @@ class Message < ApplicationRecord
assignee_id: conversation.assignee_id,
unread_count: conversation.unread_incoming_messages.count,
last_activity_at: conversation.last_activity_at.to_i,
+ **conversation.message_creation_lock_state,
contact_inbox: { source_id: conversation.contact_inbox.source_id }
}
end
@@ -285,6 +287,13 @@ class Message < ApplicationRecord
private
+ def ensure_message_creation_unlocked
+ return if conversation.blank?
+
+ locked_conversation = Conversation.lock.find(conversation.id)
+ raise CustomExceptions::ConversationMessageCreationLocked, locked_conversation if locked_conversation.message_creation_locked?
+ end
+
def prevent_message_flooding
# Added this to cover the validation specs in messages
# We can revisit and see if we can remove this later
diff --git a/app/presenters/conversations/event_data_presenter.rb b/app/presenters/conversations/event_data_presenter.rb
index 4dfa10abe..6e4b6ed87 100644
--- a/app/presenters/conversations/event_data_presenter.rb
+++ b/app/presenters/conversations/event_data_presenter.rb
@@ -17,7 +17,7 @@ class Conversations::EventDataPresenter < SimpleDelegator
first_reply_created_at: first_reply_created_at,
priority: priority,
waiting_since: waiting_since.to_i,
- **push_timestamps
+ **push_timestamps.merge(message_creation_lock_data)
}
end
@@ -49,6 +49,10 @@ class Conversations::EventDataPresenter < SimpleDelegator
}
end
+ def message_creation_lock_data
+ message_creation_lock_state
+ end
+
def push_timestamps
{
agent_last_seen_at: agent_last_seen_at.to_i,
diff --git a/app/services/automation_rules/action_service.rb b/app/services/automation_rules/action_service.rb
index 01833ba57..1f007bf30 100644
--- a/app/services/automation_rules/action_service.rb
+++ b/app/services/automation_rules/action_service.rb
@@ -12,6 +12,8 @@ class AutomationRules::ActionService < ActionService
action = action.with_indifferent_access
begin
send(action[:action_name], action[:action_params])
+ rescue CustomExceptions::ConversationMessageCreationLocked => e
+ Rails.logger.info("Skipping automation action #{action[:action_name]} because message creation is locked (#{e.message})")
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @account).capture_exception
end
diff --git a/app/services/csat_survey_service.rb b/app/services/csat_survey_service.rb
index cc38b820b..dbb4fb665 100644
--- a/app/services/csat_survey_service.rb
+++ b/app/services/csat_survey_service.rb
@@ -3,7 +3,16 @@ class CsatSurveyService
def perform
return unless should_send_csat_survey?
+ return drop_locked_csat_survey if conversation.message_creation_locked?
+ send_csat_survey
+ end
+
+ private
+
+ delegate :inbox, :contact, to: :conversation
+
+ def send_csat_survey
if whatsapp_channel? && template_available_and_approved?
send_whatsapp_template_survey
elsif inbox.twilio_whatsapp? && twilio_template_available_and_approved?
@@ -15,10 +24,6 @@ class CsatSurveyService
end
end
- private
-
- delegate :inbox, :contact, to: :conversation
-
def should_send_csat_survey?
conversation_allows_csat? && csat_enabled? && !csat_already_sent? && csat_allowed_by_survey_rules?
end
@@ -115,6 +120,8 @@ class CsatSurveyService
message_id = inbox.channel.provider_service.send_template(phone_number, template_info, message)
message.update!(source_id: message_id) if message_id.present?
+ rescue CustomExceptions::ConversationMessageCreationLocked => e
+ log_message_creation_locked(e)
rescue StandardError => e
Rails.logger.error "Error sending WhatsApp CSAT template for conversation #{conversation.id}: #{e.message}"
end
@@ -164,6 +171,8 @@ class CsatSurveyService
)
message.update!(source_id: result[:message_id]) if result[:success] && result[:message_id].present?
+ rescue CustomExceptions::ConversationMessageCreationLocked => e
+ log_message_creation_locked(e)
rescue StandardError => e
Rails.logger.error "Error sending Twilio WhatsApp CSAT template for conversation #{conversation.id}: #{e.message}"
end
@@ -178,4 +187,12 @@ class CsatSurveyService
}
::Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params) if content
end
+
+ def log_message_creation_locked(error)
+ Rails.logger.info("Skipping CSAT survey for conversation #{conversation.id} because message creation is locked (#{error.message})")
+ end
+
+ def drop_locked_csat_survey
+ log_message_creation_locked(CustomExceptions::ConversationMessageCreationLocked.new(conversation))
+ end
end
diff --git a/app/services/macros/execution_service.rb b/app/services/macros/execution_service.rb
index df82d4c6f..ac011f0a0 100644
--- a/app/services/macros/execution_service.rb
+++ b/app/services/macros/execution_service.rb
@@ -12,6 +12,8 @@ class Macros::ExecutionService < ActionService
action = action.with_indifferent_access
begin
send(action[:action_name], action[:action_params])
+ rescue CustomExceptions::ConversationMessageCreationLocked => e
+ Rails.logger.info("Skipping macro action #{action[:action_name]} because message creation is locked (#{e.message})")
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @account).capture_exception
end
diff --git a/app/services/message_templates/template/auto_resolve.rb b/app/services/message_templates/template/auto_resolve.rb
index 1b3dfa5c2..ab20ef72c 100644
--- a/app/services/message_templates/template/auto_resolve.rb
+++ b/app/services/message_templates/template/auto_resolve.rb
@@ -9,6 +9,8 @@ class MessageTemplates::Template::AutoResolve
else
create_auto_resolve_not_sent_activity_message
end
+ rescue CustomExceptions::ConversationMessageCreationLocked => e
+ Rails.logger.info("Skipping auto-resolve template because message creation is locked (#{e.message})")
end
private
diff --git a/app/services/message_templates/template/csat_survey.rb b/app/services/message_templates/template/csat_survey.rb
index 4fcef3e87..d3927fb3a 100644
--- a/app/services/message_templates/template/csat_survey.rb
+++ b/app/services/message_templates/template/csat_survey.rb
@@ -5,6 +5,8 @@ class MessageTemplates::Template::CsatSurvey
ActiveRecord::Base.transaction do
conversation.messages.create!(csat_survey_message_params)
end
+ rescue CustomExceptions::ConversationMessageCreationLocked => e
+ Rails.logger.info("Skipping CSAT survey template because message creation is locked (#{e.message})")
end
private
diff --git a/app/services/message_templates/template/email_collect.rb b/app/services/message_templates/template/email_collect.rb
index 3c0e4096f..49f7a6016 100644
--- a/app/services/message_templates/template/email_collect.rb
+++ b/app/services/message_templates/template/email_collect.rb
@@ -6,6 +6,9 @@ class MessageTemplates::Template::EmailCollect
conversation.messages.create!(ways_to_reach_you_message_params)
conversation.messages.create!(email_input_box_template_message_params)
end
+ rescue CustomExceptions::ConversationMessageCreationLocked => e
+ Rails.logger.info("Skipping email collect template because message creation is locked (#{e.message})")
+ true
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
true
diff --git a/app/services/message_templates/template/greeting.rb b/app/services/message_templates/template/greeting.rb
index 4b41c84b6..7d119d8ca 100644
--- a/app/services/message_templates/template/greeting.rb
+++ b/app/services/message_templates/template/greeting.rb
@@ -5,6 +5,9 @@ class MessageTemplates::Template::Greeting
ActiveRecord::Base.transaction do
conversation.messages.create!(greeting_message_params)
end
+ rescue CustomExceptions::ConversationMessageCreationLocked => e
+ Rails.logger.info("Skipping greeting template because message creation is locked (#{e.message})")
+ true
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
true
diff --git a/app/services/message_templates/template/out_of_office.rb b/app/services/message_templates/template/out_of_office.rb
index f3c8c24fa..f61327563 100644
--- a/app/services/message_templates/template/out_of_office.rb
+++ b/app/services/message_templates/template/out_of_office.rb
@@ -13,6 +13,9 @@ class MessageTemplates::Template::OutOfOffice
ActiveRecord::Base.transaction do
conversation.messages.create!(out_of_office_message_params)
end
+ rescue CustomExceptions::ConversationMessageCreationLocked => e
+ Rails.logger.info("Skipping out-of-office template because message creation is locked (#{e.message})")
+ true
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
true
diff --git a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder
index 4cb13f543..00d7e81cd 100644
--- a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder
+++ b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder
@@ -42,6 +42,11 @@ json.additional_attributes conversation.additional_attributes
json.agent_last_seen_at conversation.agent_last_seen_at.to_i
json.assignee_last_seen_at conversation.assignee_last_seen_at.to_i
json.can_reply conversation.can_reply?
+message_creation_lock_state = conversation.message_creation_lock_state
+json.message_limit message_creation_lock_state[:message_limit]
+json.message_limit_reached message_creation_lock_state[:message_limit_reached]
+json.message_creation_locked message_creation_lock_state[:message_creation_locked]
+json.message_creation_lock_reason message_creation_lock_state[:message_creation_lock_reason]
json.contact_last_seen_at conversation.contact_last_seen_at.to_i
json.custom_attributes conversation.custom_attributes
json.inbox_id conversation.inbox_id
diff --git a/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb b/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb
index 0301d428b..57f397201 100644
--- a/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb
@@ -3,6 +3,7 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
before_action :set_call, only: %i[show accept reject terminate upload_recording]
before_action :set_conversation, only: :initiate
+ before_action :ensure_message_creation_unlocked, only: :initiate
before_action :ensure_calling_enabled, only: :initiate
before_action :ensure_sdp_offer, only: :initiate
before_action :ensure_contact_phone, only: :initiate
@@ -84,6 +85,12 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
render_could_not_create_error(I18n.t('errors.whatsapp.calls.contact_phone_required'))
end
+ def ensure_message_creation_unlocked
+ return unless @conversation.message_creation_locked?
+
+ raise CustomExceptions::ConversationMessageCreationLocked, @conversation
+ end
+
def ensure_recording_present
return if params[:recording].present?
diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
index 7978ae947..8c0df4e94 100644
--- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb
+++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
@@ -73,8 +73,13 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
process_v1_handoff
elsif conversation_pending?
- ActiveRecord::Base.transaction do
- create_messages
+ create_messages_and_increment_usage
+ end
+ end
+
+ def create_messages_and_increment_usage
+ ActiveRecord::Base.transaction do
+ if create_messages
Rails.logger.info("[CAPTAIN][ResponseBuilderJob] Incrementing response usage for #{account.id}")
account.increment_response_usage
end
@@ -181,6 +186,10 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
additional_attributes: additional_attrs,
preserve_waiting_since: preserve_waiting_since
)
+ rescue CustomExceptions::ConversationMessageCreationLocked => e
+ Rails.logger.info(
+ "[CAPTAIN][ResponseBuilderJob] Dropped message for conversation #{@conversation.display_id}: #{e.message}"
+ )
end
def handle_error(error)
diff --git a/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb b/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb
index 0be179f04..8d8ee21f3 100644
--- a/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb
+++ b/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb
@@ -100,26 +100,30 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
end
def create_private_note(conversation, inbox, content)
- conversation.messages.create!(
- message_type: :outgoing,
- private: true,
- sender: inbox.captain_assistant,
- account_id: conversation.account_id,
- inbox_id: conversation.inbox_id,
- content: content
- )
+ create_message_or_drop(conversation, 'private note') do
+ conversation.messages.create!(
+ message_type: :outgoing,
+ private: true,
+ sender: inbox.captain_assistant,
+ account_id: conversation.account_id,
+ inbox_id: conversation.inbox_id,
+ content: content
+ )
+ end
end
def create_resolution_message(conversation, inbox)
I18n.with_locale(inbox.account.locale) do
resolution_message = inbox.captain_assistant.config['resolution_message']
- conversation.messages.create!(
- message_type: :outgoing,
- account_id: conversation.account_id,
- inbox_id: conversation.inbox_id,
- content: resolution_message.presence || I18n.t('conversations.activity.auto_resolution_message'),
- sender: inbox.captain_assistant
- )
+ create_message_or_drop(conversation, 'resolution message') do
+ conversation.messages.create!(
+ message_type: :outgoing,
+ account_id: conversation.account_id,
+ inbox_id: conversation.inbox_id,
+ content: resolution_message.presence || I18n.t('conversations.activity.auto_resolution_message'),
+ sender: inbox.captain_assistant
+ )
+ end
end
end
@@ -127,13 +131,23 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
handoff_message = inbox.captain_assistant.config['handoff_message']
return if handoff_message.blank?
- conversation.messages.create!(
- message_type: :outgoing,
- sender: inbox.captain_assistant,
- account_id: conversation.account_id,
- inbox_id: conversation.inbox_id,
- content: handoff_message,
- preserve_waiting_since: true
+ create_message_or_drop(conversation, 'handoff message') do
+ conversation.messages.create!(
+ message_type: :outgoing,
+ sender: inbox.captain_assistant,
+ account_id: conversation.account_id,
+ inbox_id: conversation.inbox_id,
+ content: handoff_message,
+ preserve_waiting_since: true
+ )
+ end
+ end
+
+ def create_message_or_drop(conversation, message_type)
+ yield
+ rescue CustomExceptions::ConversationMessageCreationLocked => e
+ Rails.logger.info(
+ "[CAPTAIN][InboxPendingConversationsResolutionJob] Dropped #{message_type} for conversation #{conversation.display_id}: #{e.message}"
)
end
end
diff --git a/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb b/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb
index 56dbc7245..5cde24064 100644
--- a/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb
+++ b/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb
@@ -57,14 +57,20 @@ module Enterprise::MessageTemplates::HookExecutionService
return unless conversation.pending?
Rails.logger.info("Captain limit exceeded, performing handoff mid-conversation for conversation: #{conversation.id}")
+ create_handoff_message
+ conversation.bot_handoff!
+ send_out_of_office_message_after_handoff
+ end
+
+ def create_handoff_message
conversation.messages.create!(
message_type: :outgoing,
account_id: conversation.account.id,
inbox_id: conversation.inbox.id,
content: 'Transferring to another agent for further assistance.'
)
- conversation.bot_handoff!
- send_out_of_office_message_after_handoff
+ rescue CustomExceptions::ConversationMessageCreationLocked => e
+ Rails.logger.info("[CaptainHandoff] Dropped handoff message for conversation #{conversation.display_id}: #{e.message}")
end
def send_out_of_office_message_after_handoff
diff --git a/enterprise/lib/captain/tools/add_private_note_tool.rb b/enterprise/lib/captain/tools/add_private_note_tool.rb
index 36e1ef977..b767f9f8b 100644
--- a/enterprise/lib/captain/tools/add_private_note_tool.rb
+++ b/enterprise/lib/captain/tools/add_private_note_tool.rb
@@ -9,7 +9,7 @@ class Captain::Tools::AddPrivateNoteTool < Captain::Tools::BasePublicTool
return 'Note content is required' if note.blank?
log_tool_usage('add_private_note', { conversation_id: conversation.id, note_length: note.length })
- create_private_note(conversation, note)
+ return 'Message creation is locked for this conversation' unless create_private_note(conversation, note)
'Private note added successfully'
end
@@ -25,6 +25,9 @@ class Captain::Tools::AddPrivateNoteTool < Captain::Tools::BasePublicTool
content: note,
private: true
)
+ rescue CustomExceptions::ConversationMessageCreationLocked => e
+ Rails.logger.info("[CAPTAIN][AddPrivateNoteTool] Dropped private note for conversation #{conversation.display_id}: #{e.message}")
+ nil
end
def permissions
diff --git a/enterprise/lib/captain/tools/handoff_tool.rb b/enterprise/lib/captain/tools/handoff_tool.rb
index d126840be..001fa8b6d 100644
--- a/enterprise/lib/captain/tools/handoff_tool.rb
+++ b/enterprise/lib/captain/tools/handoff_tool.rb
@@ -25,14 +25,7 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
def trigger_handoff(conversation, reason)
# post the reason as a private note
- conversation.messages.create!(
- message_type: :outgoing,
- private: true,
- sender: @assistant,
- account: conversation.account,
- inbox: conversation.inbox,
- content: reason
- )
+ create_private_note(conversation, reason)
# Trigger the bot handoff (sets status to open + dispatches events)
conversation.bot_handoff!
@@ -49,6 +42,19 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
end
+ def create_private_note(conversation, reason)
+ conversation.messages.create!(
+ message_type: :outgoing,
+ private: true,
+ sender: @assistant,
+ account: conversation.account,
+ inbox: conversation.inbox,
+ content: reason
+ )
+ rescue CustomExceptions::ConversationMessageCreationLocked => e
+ Rails.logger.info("[CAPTAIN][HandoffTool] Dropped handoff note for conversation #{conversation.display_id}: #{e.message}")
+ end
+
# TODO: Future enhancement - Add team assignment capability
# This tool could be enhanced to:
# 1. Accept team_id parameter for routing to specific teams
diff --git a/lib/custom_exceptions/conversation_message_creation_locked.rb b/lib/custom_exceptions/conversation_message_creation_locked.rb
new file mode 100644
index 000000000..9cc7835eb
--- /dev/null
+++ b/lib/custom_exceptions/conversation_message_creation_locked.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+class CustomExceptions::ConversationMessageCreationLocked < CustomExceptions::Base
+ ERROR_CODE = 'conversation_message_creation_locked'
+
+ def message
+ if lock_state[:message_creation_lock_reason] == 'message_limit'
+ "This conversation has reached the #{lock_state[:message_limit]} message limit. We will drop all messages after this limit."
+ else
+ 'This conversation is locked. We will drop all new messages until it is unlocked.'
+ end
+ end
+
+ def http_status
+ :unprocessable_entity
+ end
+
+ def to_hash
+ {
+ error: message,
+ message: message,
+ error_code: ERROR_CODE,
+ **lock_state
+ }
+ end
+
+ private
+
+ def lock_state
+ @lock_state ||= @data.message_creation_lock_state
+ end
+end
diff --git a/lib/integrations/bot_processor_service.rb b/lib/integrations/bot_processor_service.rb
index 8249a2993..4db02ba4b 100644
--- a/lib/integrations/bot_processor_service.rb
+++ b/lib/integrations/bot_processor_service.rb
@@ -6,6 +6,8 @@ class Integrations::BotProcessorService
return unless should_run_processor?(message)
process_content(message)
+ rescue CustomExceptions::ConversationMessageCreationLocked => e
+ Rails.logger.info("Skipping bot processor response because message creation is locked (#{e.message})")
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: (hook&.account || agent_bot&.account)).capture_exception
end
diff --git a/lib/integrations/dyte/processor_service.rb b/lib/integrations/dyte/processor_service.rb
index f74332b32..b23c53014 100644
--- a/lib/integrations/dyte/processor_service.rb
+++ b/lib/integrations/dyte/processor_service.rb
@@ -2,6 +2,8 @@ class Integrations::Dyte::ProcessorService
pattr_initialize [:account!, :conversation!]
def create_a_meeting(agent)
+ raise CustomExceptions::ConversationMessageCreationLocked, conversation if conversation.message_creation_locked?
+
return missing_realtimekit_credentials_response if realtimekit_credentials_missing?
title = I18n.t('integration_apps.dyte.meeting_name', agent_name: agent.available_name)
diff --git a/lib/integrations/slack/slack_message_helper.rb b/lib/integrations/slack/slack_message_helper.rb
index 110156b06..7fe58ad44 100644
--- a/lib/integrations/slack/slack_message_helper.rb
+++ b/lib/integrations/slack/slack_message_helper.rb
@@ -7,6 +7,9 @@ module Integrations::Slack::SlackMessageHelper
rescue Slack::Web::Api::Errors::MissingScope => e
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
disable_and_reauthorize
+ rescue CustomExceptions::ConversationMessageCreationLocked => e
+ Rails.logger.info("[SlackMessageHelper] Dropped message for conversation #{conversation.display_id}: #{e.message}")
+ success_response
end
def handle_conversation
diff --git a/lib/limits.rb b/lib/limits.rb
index ce8ab7872..711e851d0 100644
--- a/lib/limits.rb
+++ b/lib/limits.rb
@@ -14,4 +14,11 @@ module Limits
def self.conversation_message_per_minute_limit
ENV.fetch('CONVERSATION_MESSAGE_PER_MINUTE_LIMIT', '200').to_i
end
+
+ def self.conversation_message_limit
+ limit = Integer(ENV.fetch('CONVERSATION_MESSAGE_LIMIT', '10000'), 10)
+ raise ArgumentError, 'CONVERSATION_MESSAGE_LIMIT must be greater than 0' unless limit.positive?
+
+ limit
+ end
end
diff --git a/script/conversation_message_lock.rb b/script/conversation_message_lock.rb
new file mode 100644
index 000000000..fd3a9272e
--- /dev/null
+++ b/script/conversation_message_lock.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+USAGE = <<~USAGE
+ Usage:
+ bundle exec rails runner script/conversation_message_lock.rb ACCOUNT_ID CONVERSATION_DISPLAY_ID lock "reason"
+ bundle exec rails runner script/conversation_message_lock.rb ACCOUNT_ID CONVERSATION_DISPLAY_ID unlock
+USAGE
+
+account_id, conversation_display_id, action, reason = ARGV
+
+abort USAGE if account_id.blank? || conversation_display_id.blank? || action.blank?
+
+conversation = Account.find(account_id).conversations.find_by!(display_id: conversation_display_id)
+
+case action
+when 'lock'
+ conversation.lock_message_creation!(reason: reason)
+ puts "Locked message creation for conversation #{conversation.display_id}"
+when 'unlock'
+ conversation.unlock_message_creation!
+ puts "Unlocked message creation for conversation #{conversation.display_id}"
+else
+ abort USAGE
+end
diff --git a/spec/builders/messages/facebook/message_builder_spec.rb b/spec/builders/messages/facebook/message_builder_spec.rb
index 0468c2c09..0d5a66abe 100644
--- a/spec/builders/messages/facebook/message_builder_spec.rb
+++ b/spec/builders/messages/facebook/message_builder_spec.rb
@@ -297,6 +297,23 @@ describe Messages::Facebook::MessageBuilder do
expect(facebook_channel.inbox.conversations.last.id).not_to eq(existing_conversation.id)
expect(Conversation.count).to eq(inital_count + 1)
end
+
+ it 'drops the message without reporting an exception when the conversation message limit is reached' do
+ existing_conversation = create(:conversation, account_id: facebook_channel.inbox.account.id, inbox_id: facebook_channel.inbox.id,
+ contact_id: contact.id, contact_inbox_id: contact_inbox.id,
+ status: :open)
+ create(:message, conversation: existing_conversation, account: facebook_channel.inbox.account, inbox: facebook_channel.inbox)
+
+ allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
+ allow(fb_object).to receive(:get_object).and_return(
+ { first_name: 'Jane', last_name: 'Dae', profile_pic: 'https://chatwoot-assets.local/sample.png' }.with_indifferent_access
+ )
+ expect(ChatwootExceptionTracker).not_to receive(:new)
+
+ with_modified_env CONVERSATION_MESSAGE_LIMIT: '1' do
+ expect { mocked_message_builder }.not_to(change { existing_conversation.messages.count })
+ end
+ end
end
context 'when lock to single conversation is enabled' do
diff --git a/spec/builders/messages/instagram/messenger/message_builder_spec.rb b/spec/builders/messages/instagram/messenger/message_builder_spec.rb
index 07170d789..c6a4fca63 100644
--- a/spec/builders/messages/instagram/messenger/message_builder_spec.rb
+++ b/spec/builders/messages/instagram/messenger/message_builder_spec.rb
@@ -101,6 +101,28 @@ describe Messages::Instagram::Messenger::MessageBuilder do
)
end
+ it 'drops the message without reporting an exception when the conversation message limit is reached' do
+ messaging = dm_params[:entry][0]['messaging'][0]
+ sender_id = messaging['sender']['id']
+ contact = create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
+ contact_inbox = contact.contact_inboxes.find_by!(inbox: instagram_messenger_inbox)
+ conversation = create(
+ :conversation,
+ account_id: account.id,
+ inbox_id: instagram_messenger_inbox.id,
+ contact_id: contact.id,
+ contact_inbox_id: contact_inbox.id,
+ additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' }
+ )
+ create(:message, conversation: conversation, account: account, inbox: instagram_messenger_inbox)
+
+ expect(ChatwootExceptionTracker).not_to receive(:new)
+
+ with_modified_env CONVERSATION_MESSAGE_LIMIT: '1' do
+ expect { described_class.new(messaging, instagram_messenger_inbox).perform }.not_to(change { conversation.messages.count })
+ end
+ end
+
it 'creates message with for reply with story id' do
messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
sender_id = messaging['sender']['id']
diff --git a/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb
index 9ab8d7316..42fd04850 100644
--- a/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb
@@ -82,6 +82,28 @@ RSpec.describe 'Conversation Messages API', type: :request do
expect(conversation.messages.last.attachments.first.file_type).to eq('image')
end
+ it 'returns structured lock metadata when message creation is locked' do
+ with_modified_env 'CONVERSATION_MESSAGE_LIMIT': '1' do
+ create(:message, conversation: conversation, account: account, inbox: inbox)
+
+ post api_v1_account_conversation_messages_url(account_id: account.id, conversation_id: conversation.display_id),
+ params: { content: 'test-message', private: true },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ json_response = response.parsed_body
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(json_response).to include(
+ 'error_code' => 'conversation_message_creation_locked',
+ 'message_limit' => 1,
+ 'message_limit_reached' => true,
+ 'message_creation_locked' => true,
+ 'message_creation_lock_reason' => 'message_limit'
+ )
+ expect(conversation.reload.messages.count).to eq(1)
+ end
+ end
+
context 'when api inbox' do
let(:api_channel) { create(:channel_api, account: account) }
let(:api_inbox) { create(:inbox, channel: api_channel, account: account) }
@@ -277,6 +299,23 @@ RSpec.describe 'Conversation Messages API', type: :request do
expect(message.reload.status).to eq('sent')
expect(message.reload.content_attributes['external_error']).to be_nil
end
+
+ it 'returns structured lock metadata when retry is locked' do
+ with_modified_env 'CONVERSATION_MESSAGE_LIMIT': '1' do
+ post "/api/v1/accounts/#{account.id}/conversations/#{message.conversation.display_id}/messages/#{message.id}/retry",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ json_response = response.parsed_body
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(json_response).to include(
+ 'error_code' => 'conversation_message_creation_locked',
+ 'message_creation_locked' => true,
+ 'message_creation_lock_reason' => 'message_limit'
+ )
+ expect(message.reload.status).to eq('failed')
+ end
+ end
end
context 'when the message id is invalid' do
diff --git a/spec/controllers/api/v1/accounts/integrations/dyte_controller_spec.rb b/spec/controllers/api/v1/accounts/integrations/dyte_controller_spec.rb
index 4f401d48f..1daf5c0ba 100644
--- a/spec/controllers/api/v1/accounts/integrations/dyte_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/integrations/dyte_controller_spec.rb
@@ -60,6 +60,27 @@ RSpec.describe 'Dyte Integration API', type: :request do
expect(conversation.display_id).to eq(response_body['conversation_id'])
expect(last_message.id).to eq(response_body['id'])
end
+
+ it 'returns lock metadata without creating an external meeting when message creation is locked' do
+ create(:message, conversation: conversation, account: account, inbox: conversation.inbox)
+ expect(Dyte).not_to receive(:new)
+
+ with_modified_env CONVERSATION_MESSAGE_LIMIT: '1' do
+ post create_a_meeting_api_v1_account_integrations_dyte_url(account),
+ params: { conversation_id: conversation.display_id },
+ headers: agent.create_new_auth_token,
+ as: :json
+ end
+
+ response_body = response.parsed_body
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response_body).to include(
+ 'error_code' => 'conversation_message_creation_locked',
+ 'message_creation_locked' => true,
+ 'message_creation_lock_reason' => 'message_limit'
+ )
+ expect(conversation.reload.messages.count).to eq(1)
+ end
end
context 'when it is an agent with inbox access and the Dyte API is errored' do
diff --git a/spec/controllers/api/v1/webhooks_controller_spec.rb b/spec/controllers/api/v1/webhooks_controller_spec.rb
new file mode 100644
index 000000000..104a5aed6
--- /dev/null
+++ b/spec/controllers/api/v1/webhooks_controller_spec.rb
@@ -0,0 +1,19 @@
+require 'rails_helper'
+
+RSpec.describe 'Webhooks API', type: :request do
+ describe 'POST /webhooks/twitter' do
+ it 'drops message creation locks without reporting an exception' do
+ conversation = create(:conversation)
+ conversation.lock_message_creation!
+ consumer = instance_double(Webhooks::Twitter)
+
+ allow(Webhooks::Twitter).to receive(:new).and_return(consumer)
+ allow(consumer).to receive(:consume).and_raise(CustomExceptions::ConversationMessageCreationLocked.new(conversation))
+ expect(ChatwootExceptionTracker).not_to receive(:new)
+
+ post '/webhooks/twitter', params: { direct_message_events: [] }
+
+ expect(response).to have_http_status(:ok)
+ end
+ end
+end
diff --git a/spec/controllers/api/v1/widget/messages_controller_spec.rb b/spec/controllers/api/v1/widget/messages_controller_spec.rb
index 3d4ec83ca..faab1aef4 100644
--- a/spec/controllers/api/v1/widget/messages_controller_spec.rb
+++ b/spec/controllers/api/v1/widget/messages_controller_spec.rb
@@ -130,6 +130,30 @@ RSpec.describe '/api/v1/widget/messages', type: :request do
expect(json_response['message']).to eq('Content is too long (maximum is 150000 characters)')
end
+ it 'returns structured lock metadata when message creation is locked', :skip_before do
+ create(:message, account: account, inbox: web_widget.inbox, conversation: conversation)
+ message_params = { content: 'hello world', timestamp: Time.current }
+ message_count_before_request = conversation.reload.messages.count
+
+ with_modified_env CONVERSATION_MESSAGE_LIMIT: '1' do
+ post api_v1_widget_messages_url,
+ params: { website_token: web_widget.website_token, message: message_params },
+ headers: { 'X-Auth-Token' => token },
+ as: :json
+ end
+
+ json_response = response.parsed_body
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(json_response).to include(
+ 'error_code' => 'conversation_message_creation_locked',
+ 'message_limit' => 1,
+ 'message_limit_reached' => true,
+ 'message_creation_locked' => true,
+ 'message_creation_lock_reason' => 'message_limit'
+ )
+ expect(conversation.reload.messages.count).to eq(message_count_before_request)
+ end
+
it 'creates message in conversation with a valid reply to' do
message_params = { content: 'hello world reply', timestamp: Time.current, reply_to: conversation.messages.first.id }
post api_v1_widget_messages_url,
diff --git a/spec/controllers/public/api/v1/inbox/messages_controller_spec.rb b/spec/controllers/public/api/v1/inbox/messages_controller_spec.rb
index c6650511b..c912d6e30 100644
--- a/spec/controllers/public/api/v1/inbox/messages_controller_spec.rb
+++ b/spec/controllers/public/api/v1/inbox/messages_controller_spec.rb
@@ -53,6 +53,27 @@ RSpec.describe 'Public Inbox Contact Conversation Messages API', type: :request
expect(json_response['message']).to eq('Content is too long (maximum is 150000 characters)')
end
+ it 'returns structured lock metadata when message creation is locked' do
+ create(:message, account: conversation.account, inbox: conversation.inbox, conversation: conversation)
+ message_count_before_request = conversation.reload.messages.count
+
+ with_modified_env CONVERSATION_MESSAGE_LIMIT: '1' do
+ post "/public/api/v1/inboxes/#{api_channel.identifier}/contacts/#{contact_inbox.source_id}/conversations/#{conversation.display_id}/messages",
+ params: { content: 'hello' }
+ end
+
+ json_response = response.parsed_body
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(json_response).to include(
+ 'error_code' => 'conversation_message_creation_locked',
+ 'message_limit' => 1,
+ 'message_limit_reached' => true,
+ 'message_creation_locked' => true,
+ 'message_creation_lock_reason' => 'message_limit'
+ )
+ expect(conversation.reload.messages.count).to eq(message_count_before_request)
+ end
+
it 'creates attachment message in conversation' do
file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
post "/public/api/v1/inboxes/#{api_channel.identifier}/contacts/#{contact_inbox.source_id}/conversations/#{conversation.display_id}/messages",
diff --git a/spec/enterprise/controllers/api/v1/accounts/whatsapp_calls_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/whatsapp_calls_controller_spec.rb
index 500255983..448e42f8c 100644
--- a/spec/enterprise/controllers/api/v1/accounts/whatsapp_calls_controller_spec.rb
+++ b/spec/enterprise/controllers/api/v1/accounts/whatsapp_calls_controller_spec.rb
@@ -104,6 +104,24 @@ RSpec.describe 'WhatsApp Calls API', type: :request do
expect(Call.find_by(provider_call_id: 'wacid_outbound')).to have_attributes(direction: 'outgoing', status: 'ringing')
end
+ it 'returns the lock response without calling Meta when the conversation is message-creation locked' do
+ initiate_conversation.lock_message_creation!(reason: 'manual')
+ expect(provider_service).not_to receive(:initiate_call)
+
+ expect do
+ post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
+ params: { conversation_id: initiate_conversation.display_id, sdp_offer: 'sdp_offer' },
+ headers: agent.create_new_auth_token
+ end.not_to change(Call, :count)
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body).to include(
+ 'error_code' => 'conversation_message_creation_locked',
+ 'message_creation_locked' => true,
+ 'message_creation_lock_reason' => 'manual'
+ )
+ end
+
it 'sends a permission request and records the wamid when Meta returns NoCallPermission' do
allow(provider_service).to receive(:initiate_call).and_raise(Voice::CallErrors::NoCallPermission)
allow(provider_service).to receive(:send_call_permission_request).and_return({ 'messages' => [{ 'id' => 'wamid.req_xyz' }] })
diff --git a/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb b/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb
index f432aae62..c00db9757 100644
--- a/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb
+++ b/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb
@@ -19,6 +19,11 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
end
context 'when captain_tasks is disabled' do
+ before do
+ allow(inbox.account).to receive(:feature_enabled?).and_call_original
+ allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(false)
+ end
+
it 'resolves pending conversations inactive for over 1 hour' do
described_class.perform_now(inbox)
diff --git a/spec/enterprise/lib/captain/tools/add_private_note_tool_spec.rb b/spec/enterprise/lib/captain/tools/add_private_note_tool_spec.rb
index cfce1a7d1..c29f3b80f 100644
--- a/spec/enterprise/lib/captain/tools/add_private_note_tool_spec.rb
+++ b/spec/enterprise/lib/captain/tools/add_private_note_tool_spec.rb
@@ -59,6 +59,18 @@ RSpec.describe Captain::Tools::AddPrivateNoteTool, type: :model do
tool.perform(tool_context, note: 'This is a test note')
end
+
+ it 'returns a locked response when the private note is dropped by the message lock' do
+ with_modified_env 'CONVERSATION_MESSAGE_LIMIT': '1' do
+ create(:message, conversation: conversation, account: account, inbox: inbox)
+ expect(ChatwootExceptionTracker).not_to receive(:new)
+
+ expect do
+ result = tool.perform(tool_context, note: 'This is a private note')
+ expect(result).to eq('Message creation is locked for this conversation')
+ end.not_to change(Message, :count)
+ end
+ end
end
context 'with blank note content' do
diff --git a/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb b/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb
index 492d24f32..4aec7d0b4 100644
--- a/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb
+++ b/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb
@@ -86,6 +86,22 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do
tool.perform(tool_context, reason: reason)
end
+
+ it 'hands off even when the private note is dropped by the message lock' do
+ conversation.update!(status: :pending)
+
+ with_modified_env 'CONVERSATION_MESSAGE_LIMIT': '1' do
+ create(:message, conversation: conversation, account: account, inbox: inbox)
+ expect(ChatwootExceptionTracker).not_to receive(:new)
+
+ expect do
+ result = tool.perform(tool_context, reason: 'Customer needs specialized support')
+ expect(result).to eq('Conversation handed off to human support team (Reason: Customer needs specialized support)')
+ end.not_to change(Message, :count)
+
+ expect(conversation.reload.status).to eq('open')
+ end
+ end
end
context 'without reason provided' do
diff --git a/spec/enterprise/services/enterprise/message_templates/hook_execution_service_spec.rb b/spec/enterprise/services/enterprise/message_templates/hook_execution_service_spec.rb
index 9eacb0ba1..89238fdeb 100644
--- a/spec/enterprise/services/enterprise/message_templates/hook_execution_service_spec.rb
+++ b/spec/enterprise/services/enterprise/message_templates/hook_execution_service_spec.rb
@@ -99,6 +99,20 @@ RSpec.describe MessageTemplates::HookExecutionService do
expect(conversation.reload.status).to eq('open')
end
+
+ it 'performs handoff when the handoff message is dropped by the conversation message limit' do
+ create(:message, conversation: conversation, message_type: :outgoing, account: account)
+
+ expect(ChatwootExceptionTracker).not_to receive(:new)
+
+ with_modified_env CONVERSATION_MESSAGE_LIMIT: '2' do
+ expect do
+ create(:message, conversation: conversation, message_type: :incoming, account: account)
+ end.to change { conversation.messages.count }.by(1)
+ end
+
+ expect(conversation.reload.status).to eq('open')
+ end
end
end
diff --git a/spec/jobs/conversations/activity_message_job_spec.rb b/spec/jobs/conversations/activity_message_job_spec.rb
new file mode 100644
index 000000000..ea89cb8a8
--- /dev/null
+++ b/spec/jobs/conversations/activity_message_job_spec.rb
@@ -0,0 +1,23 @@
+require 'rails_helper'
+
+RSpec.describe Conversations::ActivityMessageJob do
+ describe '#perform' do
+ let(:conversation) { create(:conversation) }
+ let(:message_params) do
+ {
+ account_id: conversation.account_id,
+ inbox_id: conversation.inbox_id,
+ message_type: :activity,
+ content: 'Conversation activity'
+ }
+ end
+
+ it 'drops locked activity messages without raising' do
+ conversation.lock_message_creation!
+
+ expect do
+ described_class.perform_now(conversation, message_params)
+ end.not_to change(Message, :count)
+ end
+ end
+end
diff --git a/spec/lib/integrations/bot_processor_service_spec.rb b/spec/lib/integrations/bot_processor_service_spec.rb
new file mode 100644
index 000000000..5c5805235
--- /dev/null
+++ b/spec/lib/integrations/bot_processor_service_spec.rb
@@ -0,0 +1,17 @@
+require 'rails_helper'
+
+RSpec.describe Integrations::BotProcessorService do
+ describe '#perform' do
+ let(:conversation) { create(:conversation) }
+ let(:message) { create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox) }
+ let(:service) { described_class.new(event_name: 'message.created', hook: nil, event_data: { message: message }) }
+
+ it 'drops locked bot responses without reporting an exception' do
+ allow(service).to receive(:should_run_processor?).and_return(true)
+ allow(service).to receive(:process_content).and_raise(CustomExceptions::ConversationMessageCreationLocked.new(conversation))
+ expect(ChatwootExceptionTracker).not_to receive(:new)
+
+ expect { service.perform }.not_to raise_error
+ end
+ end
+end
diff --git a/spec/lib/integrations/slack/incoming_message_builder_spec.rb b/spec/lib/integrations/slack/incoming_message_builder_spec.rb
index 65234767e..c732fdd4b 100644
--- a/spec/lib/integrations/slack/incoming_message_builder_spec.rb
+++ b/spec/lib/integrations/slack/incoming_message_builder_spec.rb
@@ -96,6 +96,18 @@ describe Integrations::Slack::IncomingMessageBuilder do
expect(conversation.messages.last.private).to be(true)
end
+ it 'drops message creation when the conversation is locked' do
+ create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox)
+
+ with_modified_env CONVERSATION_MESSAGE_LIMIT: '1' do
+ builder = described_class.new(message_params)
+ allow(builder).to receive(:resolve_slack_sender).and_return([nil, nil, nil])
+
+ expect(builder.perform).to eq({ status: 'success' })
+ expect(conversation.reload.messages.count).to eq(1)
+ end
+ end
+
it 'does not create message for invalid event type' do
messages_count = conversation.messages.count
message_params[:type] = 'invalid_event_type'
diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb
index 58d64ea94..4cb9a95c7 100644
--- a/spec/models/conversation_spec.rb
+++ b/spec/models/conversation_spec.rb
@@ -104,6 +104,53 @@ RSpec.describe Conversation do
end
end
+ describe 'message creation lock' do
+ let(:conversation) { create(:conversation) }
+ let(:message_params) { { conversation: conversation, account: conversation.account, inbox: conversation.inbox } }
+
+ it 'detects when the message limit has been reached' do
+ with_modified_env 'CONVERSATION_MESSAGE_LIMIT': '2' do
+ create(:message, **message_params)
+
+ expect(conversation.reload.message_limit).to eq(2)
+ expect(conversation.message_limit_reached?).to be false
+
+ create(:message, **message_params)
+
+ expect(conversation.reload.message_limit_reached?).to be true
+ expect(conversation.message_creation_locked?).to be true
+ expect(conversation.message_creation_lock_reason).to eq('message_limit')
+ end
+ end
+
+ it 'stores and clears manual lock metadata' do
+ freeze_time do
+ conversation.lock_message_creation!(reason: 'maintenance')
+
+ lock_data = conversation.reload.additional_attributes['message_creation_lock']
+ expect(lock_data).to include(
+ 'locked' => true,
+ 'reason' => 'maintenance',
+ 'locked_at' => Time.current.iso8601
+ )
+ expect(conversation.manual_message_creation_locked?).to be true
+ expect(conversation.message_creation_locked?).to be true
+ expect(conversation.message_creation_lock_reason).to eq('manual')
+
+ conversation.unlock_message_creation!
+
+ expect(conversation.reload.additional_attributes).not_to have_key('message_creation_lock')
+ expect(conversation.message_creation_locked?).to be false
+ end
+ end
+
+ it 'fails loudly when the message limit config is invalid' do
+ with_modified_env 'CONVERSATION_MESSAGE_LIMIT': 'invalid' do
+ expect { conversation.message_limit }.to raise_error(ArgumentError)
+ end
+ end
+ end
+
describe '.after_update' do
let!(:account) { create(:account) }
let!(:old_assignee) do
@@ -620,6 +667,10 @@ RSpec.describe Conversation do
contact_inbox: conversation.contact_inbox,
timestamp: conversation.last_activity_at.to_i,
can_reply: true,
+ message_limit: conversation.message_limit,
+ message_limit_reached: conversation.message_limit_reached?,
+ message_creation_locked: conversation.message_creation_locked?,
+ message_creation_lock_reason: conversation.message_creation_lock_reason,
channel: 'Channel::WebWidget',
snoozed_until: conversation.snoozed_until,
custom_attributes: conversation.custom_attributes,
diff --git a/spec/models/message_spec.rb b/spec/models/message_spec.rb
index e331f68ac..528bb4630 100644
--- a/spec/models/message_spec.rb
+++ b/spec/models/message_spec.rb
@@ -141,6 +141,10 @@ RSpec.describe Message do
source_id: message.conversation.contact_inbox.source_id
},
last_activity_at: message.conversation.last_activity_at.to_i,
+ message_limit: message.conversation.message_limit,
+ message_limit_reached: message.conversation.message_limit_reached?,
+ message_creation_locked: message.conversation.message_creation_locked?,
+ message_creation_lock_reason: message.conversation.message_creation_lock_reason,
unread_count: message.conversation.unread_incoming_messages.count
},
sentiment: {},
@@ -154,6 +158,47 @@ RSpec.describe Message do
end
end
+ describe 'message creation lock' do
+ let(:conversation) { create(:conversation) }
+ let(:message_params) { { conversation: conversation, account: conversation.account, inbox: conversation.inbox } }
+ let(:locked_error_name) { 'CustomExceptions::ConversationMessageCreationLocked' }
+
+ it 'allows the capped message and blocks the next message' do
+ with_modified_env 'CONVERSATION_MESSAGE_LIMIT': '2' do
+ create(:message, **message_params)
+
+ expect { create(:message, **message_params) }.to change(described_class, :count).by(1)
+ expect { create(:message, **message_params) }
+ .to raise_error(StandardError) { |error| expect(error.class.name).to eq(locked_error_name) }
+ end
+ end
+
+ it 'counts incoming, outgoing, template, private, and activity messages' do
+ with_modified_env 'CONVERSATION_MESSAGE_LIMIT': '5' do
+ create(:message, message_type: :incoming, **message_params)
+ create(:message, message_type: :outgoing, **message_params)
+ create(:message, message_type: :template, **message_params)
+ create(:message, message_type: :outgoing, private: true, **message_params)
+ create(:message, message_type: :activity, **message_params)
+
+ expect(conversation.reload.message_limit_reached?).to be true
+ expect { create(:message, **message_params) }
+ .to raise_error(StandardError) { |error| expect(error.class.name).to eq(locked_error_name) }
+ end
+ end
+
+ it 'blocks message creation when manually locked and allows it after unlock' do
+ conversation.lock_message_creation!(reason: 'ops')
+
+ expect { create(:message, **message_params) }
+ .to raise_error(StandardError) { |error| expect(error.class.name).to eq(locked_error_name) }
+
+ conversation.unlock_message_creation!
+
+ expect { create(:message, **message_params) }.to change(described_class, :count).by(1)
+ end
+ end
+
describe 'message create event' do
let!(:conversation) { create(:conversation) }
diff --git a/spec/presenters/conversations/event_data_presenter_spec.rb b/spec/presenters/conversations/event_data_presenter_spec.rb
index 21cb26c98..c8c1174ac 100644
--- a/spec/presenters/conversations/event_data_presenter_spec.rb
+++ b/spec/presenters/conversations/event_data_presenter_spec.rb
@@ -24,6 +24,10 @@ RSpec.describe Conversations::EventDataPresenter do
status: conversation.status,
contact_inbox: conversation.contact_inbox,
can_reply: conversation.can_reply?,
+ message_limit: conversation.message_limit,
+ message_limit_reached: conversation.message_limit_reached?,
+ message_creation_locked: conversation.message_creation_locked?,
+ message_creation_lock_reason: conversation.message_creation_lock_reason,
channel: conversation.inbox.channel_type,
timestamp: conversation.last_activity_at.to_i,
snoozed_until: conversation.snoozed_until,
diff --git a/spec/services/automation_rules/action_service_spec.rb b/spec/services/automation_rules/action_service_spec.rb
index f24617d29..1fd514404 100644
--- a/spec/services/automation_rules/action_service_spec.rb
+++ b/spec/services/automation_rules/action_service_spec.rb
@@ -73,6 +73,18 @@ RSpec.describe AutomationRules::ActionService do
expect(message_builder).not_to receive(:perform)
described_class.new(rule, account, conversation).perform
end
+
+ it 'drops locked message actions without reporting an exception' do
+ rule.update!(actions: [{ action_name: 'send_message', action_params: ['Hello'] }])
+
+ with_modified_env 'CONVERSATION_MESSAGE_LIMIT': '1' do
+ create(:message, conversation: conversation, account: account, inbox: conversation.inbox)
+ allow(Messages::MessageBuilder).to receive(:new).and_call_original
+ expect(ChatwootExceptionTracker).not_to receive(:new)
+
+ expect { described_class.new(rule.reload, account, conversation).perform }.not_to change(Message, :count)
+ end
+ end
end
describe '#perform with send_email_to_team action' do
diff --git a/spec/services/csat_survey_service_spec.rb b/spec/services/csat_survey_service_spec.rb
index 5a62e32e5..1710dc6f2 100644
--- a/spec/services/csat_survey_service_spec.rb
+++ b/spec/services/csat_survey_service_spec.rb
@@ -28,6 +28,33 @@ describe CsatSurveyService do
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: conversation)
expect(csat_template).to have_received(:perform)
end
+
+ it 'drops CSAT survey creation when the conversation is locked' do
+ create(:message, conversation: conversation, account: account, inbox: inbox)
+
+ with_modified_env CONVERSATION_MESSAGE_LIMIT: '1' do
+ service.perform
+ end
+
+ expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
+ expect(Conversations::ActivityMessageJob).not_to have_received(:perform_later)
+ expect(conversation.reload.messages.count).to eq(1)
+ end
+
+ it 'drops Twilio WhatsApp template survey before checking template status when the conversation is locked' do
+ create(:message, conversation: conversation, account: account, inbox: inbox)
+ inbox.update(csat_config: { 'template' => { 'content_sid' => 'HX123' } })
+ allow(conversation).to receive(:inbox).and_return(inbox)
+ allow(inbox).to receive(:twilio_whatsapp?).and_return(true)
+ expect(Twilio::CsatTemplateService).not_to receive(:new)
+
+ with_modified_env CONVERSATION_MESSAGE_LIMIT: '1' do
+ service.perform
+ end
+
+ expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
+ expect(conversation.reload.messages.count).to eq(1)
+ end
end
context 'when outside messaging window' do
@@ -217,6 +244,19 @@ describe CsatSurveyService do
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
expect(csat_message.content).to eq('Please rate this conversation')
end
+
+ it 'drops WhatsApp template survey before checking template status when the conversation is locked' do
+ create(:message, conversation: whatsapp_conversation, account: account, inbox: whatsapp_inbox)
+ expect(mock_provider_service).not_to receive(:get_template_status)
+ expect(mock_provider_service).not_to receive(:send_template)
+
+ with_modified_env CONVERSATION_MESSAGE_LIMIT: '1' do
+ whatsapp_service.perform
+ end
+
+ expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
+ expect(whatsapp_conversation.reload.messages.count).to eq(1)
+ end
end
context 'when template is not available or not approved' do
diff --git a/spec/services/macros/execution_service_spec.rb b/spec/services/macros/execution_service_spec.rb
index d44f7793a..168e7eb48 100644
--- a/spec/services/macros/execution_service_spec.rb
+++ b/spec/services/macros/execution_service_spec.rb
@@ -45,6 +45,19 @@ RSpec.describe Macros::ExecutionService, type: :service do
service.perform
end
+
+ it 'drops locked message actions without reporting an exception' do
+ allow(macro).to receive(:actions).and_return([
+ { action_name: 'send_message', action_params: ['Locked message'] }
+ ])
+
+ with_modified_env 'CONVERSATION_MESSAGE_LIMIT': '1' do
+ create(:message, conversation: conversation, account: account, inbox: conversation.inbox)
+ expect(ChatwootExceptionTracker).not_to receive(:new)
+
+ expect { service.perform }.not_to change(Message, :count)
+ end
+ end
end
end
end
diff --git a/spec/services/message_templates/template/greeting_spec.rb b/spec/services/message_templates/template/greeting_spec.rb
index d7fc7690f..d8691c766 100644
--- a/spec/services/message_templates/template/greeting_spec.rb
+++ b/spec/services/message_templates/template/greeting_spec.rb
@@ -29,5 +29,14 @@ describe MessageTemplates::Template::Greeting do
expect(conversation.messages.count).to eq(1)
expect(conversation.messages.last.content).to eq('Hello welcome to our board.')
end
+
+ it 'drops locked greeting messages without reporting an exception' do
+ with_modified_env 'CONVERSATION_MESSAGE_LIMIT': '1' do
+ create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox)
+ expect(ChatwootExceptionTracker).not_to receive(:new)
+
+ expect { described_class.new(conversation: conversation).perform }.not_to change(Message, :count)
+ end
+ end
end
end
diff --git a/swagger/definitions/resource/contact_conversation_message.yml b/swagger/definitions/resource/contact_conversation_message.yml
index a0334dfba..058db7b82 100644
--- a/swagger/definitions/resource/contact_conversation_message.yml
+++ b/swagger/definitions/resource/contact_conversation_message.yml
@@ -80,6 +80,21 @@ properties:
last_activity_at:
type: integer
description: Timestamp of last activity
+ message_limit:
+ type: integer
+ description: Maximum number of messages allowed in the conversation
+ message_limit_reached:
+ type: boolean
+ description: Whether the conversation has reached the message limit
+ message_creation_locked:
+ type: boolean
+ description: Whether new messages are currently blocked for the conversation
+ message_creation_lock_reason:
+ type:
+ - string
+ - 'null'
+ enum: ['message_limit', 'manual', null]
+ description: Reason new message creation is blocked
contact_inbox:
type: object
description: Contact inbox details
@@ -111,4 +126,4 @@ properties:
description: Availability status of the sender
thumbnail:
type: string
- description: Thumbnail URL of the sender
\ No newline at end of file
+ description: Thumbnail URL of the sender
diff --git a/swagger/definitions/resource/conversation.yml b/swagger/definitions/resource/conversation.yml
index a0bc7730b..d5acfe1f3 100644
--- a/swagger/definitions/resource/conversation.yml
+++ b/swagger/definitions/resource/conversation.yml
@@ -25,6 +25,21 @@ properties:
can_reply:
type: boolean
description: Whether the conversation can be replied to
+ message_limit:
+ type: integer
+ description: Maximum number of messages allowed in the conversation
+ message_limit_reached:
+ type: boolean
+ description: Whether the conversation has reached the message limit
+ message_creation_locked:
+ type: boolean
+ description: Whether new messages are currently blocked for the conversation
+ message_creation_lock_reason:
+ type:
+ - string
+ - 'null'
+ enum: ['message_limit', 'manual', null]
+ description: Reason new message creation is blocked
contact_last_seen_at:
type: number
description: The last activity at of the contact
@@ -99,4 +114,3 @@ properties:
items:
type: object
description: SLA event objects
-
diff --git a/swagger/definitions/resource/message.yml b/swagger/definitions/resource/message.yml
index a1f598bc8..7e9fb0bae 100644
--- a/swagger/definitions/resource/message.yml
+++ b/swagger/definitions/resource/message.yml
@@ -82,6 +82,22 @@ properties:
- object
- 'null'
description: The conversation object
+ properties:
+ message_limit:
+ type: integer
+ description: Maximum number of messages allowed in the conversation
+ message_limit_reached:
+ type: boolean
+ description: Whether the conversation has reached the message limit
+ message_creation_locked:
+ type: boolean
+ description: Whether new messages are currently blocked for the conversation
+ message_creation_lock_reason:
+ type:
+ - string
+ - 'null'
+ enum: ['message_limit', 'manual', null]
+ description: Reason new message creation is blocked
attachment:
type:
- object
diff --git a/swagger/swagger.json b/swagger/swagger.json
index b21742b4e..ed04f08cb 100644
--- a/swagger/swagger.json
+++ b/swagger/swagger.json
@@ -9828,6 +9828,30 @@
"type": "boolean",
"description": "Whether the conversation can be replied to"
},
+ "message_limit": {
+ "type": "integer",
+ "description": "Maximum number of messages allowed in the conversation"
+ },
+ "message_limit_reached": {
+ "type": "boolean",
+ "description": "Whether the conversation has reached the message limit"
+ },
+ "message_creation_locked": {
+ "type": "boolean",
+ "description": "Whether new messages are currently blocked for the conversation"
+ },
+ "message_creation_lock_reason": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "enum": [
+ "message_limit",
+ "manual",
+ null
+ ],
+ "description": "Reason new message creation is blocked"
+ },
"contact_last_seen_at": {
"type": "number",
"description": "The last activity at of the contact"
@@ -10083,7 +10107,33 @@
"object",
"null"
],
- "description": "The conversation object"
+ "description": "The conversation object",
+ "properties": {
+ "message_limit": {
+ "type": "integer",
+ "description": "Maximum number of messages allowed in the conversation"
+ },
+ "message_limit_reached": {
+ "type": "boolean",
+ "description": "Whether the conversation has reached the message limit"
+ },
+ "message_creation_locked": {
+ "type": "boolean",
+ "description": "Whether new messages are currently blocked for the conversation"
+ },
+ "message_creation_lock_reason": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "enum": [
+ "message_limit",
+ "manual",
+ null
+ ],
+ "description": "Reason new message creation is blocked"
+ }
+ }
},
"attachment": {
"type": [
@@ -15464,6 +15514,30 @@
"type": "integer",
"description": "Timestamp of last activity"
},
+ "message_limit": {
+ "type": "integer",
+ "description": "Maximum number of messages allowed in the conversation"
+ },
+ "message_limit_reached": {
+ "type": "boolean",
+ "description": "Whether the conversation has reached the message limit"
+ },
+ "message_creation_locked": {
+ "type": "boolean",
+ "description": "Whether new messages are currently blocked for the conversation"
+ },
+ "message_creation_lock_reason": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "enum": [
+ "message_limit",
+ "manual",
+ null
+ ],
+ "description": "Reason new message creation is blocked"
+ },
"contact_inbox": {
"type": "object",
"description": "Contact inbox details",
diff --git a/swagger/tag_groups/application_swagger.json b/swagger/tag_groups/application_swagger.json
index f9ff31a7e..c8e80858f 100644
--- a/swagger/tag_groups/application_swagger.json
+++ b/swagger/tag_groups/application_swagger.json
@@ -8335,6 +8335,30 @@
"type": "boolean",
"description": "Whether the conversation can be replied to"
},
+ "message_limit": {
+ "type": "integer",
+ "description": "Maximum number of messages allowed in the conversation"
+ },
+ "message_limit_reached": {
+ "type": "boolean",
+ "description": "Whether the conversation has reached the message limit"
+ },
+ "message_creation_locked": {
+ "type": "boolean",
+ "description": "Whether new messages are currently blocked for the conversation"
+ },
+ "message_creation_lock_reason": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "enum": [
+ "message_limit",
+ "manual",
+ null
+ ],
+ "description": "Reason new message creation is blocked"
+ },
"contact_last_seen_at": {
"type": "number",
"description": "The last activity at of the contact"
@@ -8590,7 +8614,33 @@
"object",
"null"
],
- "description": "The conversation object"
+ "description": "The conversation object",
+ "properties": {
+ "message_limit": {
+ "type": "integer",
+ "description": "Maximum number of messages allowed in the conversation"
+ },
+ "message_limit_reached": {
+ "type": "boolean",
+ "description": "Whether the conversation has reached the message limit"
+ },
+ "message_creation_locked": {
+ "type": "boolean",
+ "description": "Whether new messages are currently blocked for the conversation"
+ },
+ "message_creation_lock_reason": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "enum": [
+ "message_limit",
+ "manual",
+ null
+ ],
+ "description": "Reason new message creation is blocked"
+ }
+ }
},
"attachment": {
"type": [
@@ -13971,6 +14021,30 @@
"type": "integer",
"description": "Timestamp of last activity"
},
+ "message_limit": {
+ "type": "integer",
+ "description": "Maximum number of messages allowed in the conversation"
+ },
+ "message_limit_reached": {
+ "type": "boolean",
+ "description": "Whether the conversation has reached the message limit"
+ },
+ "message_creation_locked": {
+ "type": "boolean",
+ "description": "Whether new messages are currently blocked for the conversation"
+ },
+ "message_creation_lock_reason": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "enum": [
+ "message_limit",
+ "manual",
+ null
+ ],
+ "description": "Reason new message creation is blocked"
+ },
"contact_inbox": {
"type": "object",
"description": "Contact inbox details",
diff --git a/swagger/tag_groups/client_swagger.json b/swagger/tag_groups/client_swagger.json
index b810a4308..dfb06ff5c 100644
--- a/swagger/tag_groups/client_swagger.json
+++ b/swagger/tag_groups/client_swagger.json
@@ -1245,6 +1245,30 @@
"type": "boolean",
"description": "Whether the conversation can be replied to"
},
+ "message_limit": {
+ "type": "integer",
+ "description": "Maximum number of messages allowed in the conversation"
+ },
+ "message_limit_reached": {
+ "type": "boolean",
+ "description": "Whether the conversation has reached the message limit"
+ },
+ "message_creation_locked": {
+ "type": "boolean",
+ "description": "Whether new messages are currently blocked for the conversation"
+ },
+ "message_creation_lock_reason": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "enum": [
+ "message_limit",
+ "manual",
+ null
+ ],
+ "description": "Reason new message creation is blocked"
+ },
"contact_last_seen_at": {
"type": "number",
"description": "The last activity at of the contact"
@@ -1500,7 +1524,33 @@
"object",
"null"
],
- "description": "The conversation object"
+ "description": "The conversation object",
+ "properties": {
+ "message_limit": {
+ "type": "integer",
+ "description": "Maximum number of messages allowed in the conversation"
+ },
+ "message_limit_reached": {
+ "type": "boolean",
+ "description": "Whether the conversation has reached the message limit"
+ },
+ "message_creation_locked": {
+ "type": "boolean",
+ "description": "Whether new messages are currently blocked for the conversation"
+ },
+ "message_creation_lock_reason": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "enum": [
+ "message_limit",
+ "manual",
+ null
+ ],
+ "description": "Reason new message creation is blocked"
+ }
+ }
},
"attachment": {
"type": [
@@ -6881,6 +6931,30 @@
"type": "integer",
"description": "Timestamp of last activity"
},
+ "message_limit": {
+ "type": "integer",
+ "description": "Maximum number of messages allowed in the conversation"
+ },
+ "message_limit_reached": {
+ "type": "boolean",
+ "description": "Whether the conversation has reached the message limit"
+ },
+ "message_creation_locked": {
+ "type": "boolean",
+ "description": "Whether new messages are currently blocked for the conversation"
+ },
+ "message_creation_lock_reason": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "enum": [
+ "message_limit",
+ "manual",
+ null
+ ],
+ "description": "Reason new message creation is blocked"
+ },
"contact_inbox": {
"type": "object",
"description": "Contact inbox details",
diff --git a/swagger/tag_groups/other_swagger.json b/swagger/tag_groups/other_swagger.json
index f6e10e57c..5f7bad927 100644
--- a/swagger/tag_groups/other_swagger.json
+++ b/swagger/tag_groups/other_swagger.json
@@ -660,6 +660,30 @@
"type": "boolean",
"description": "Whether the conversation can be replied to"
},
+ "message_limit": {
+ "type": "integer",
+ "description": "Maximum number of messages allowed in the conversation"
+ },
+ "message_limit_reached": {
+ "type": "boolean",
+ "description": "Whether the conversation has reached the message limit"
+ },
+ "message_creation_locked": {
+ "type": "boolean",
+ "description": "Whether new messages are currently blocked for the conversation"
+ },
+ "message_creation_lock_reason": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "enum": [
+ "message_limit",
+ "manual",
+ null
+ ],
+ "description": "Reason new message creation is blocked"
+ },
"contact_last_seen_at": {
"type": "number",
"description": "The last activity at of the contact"
@@ -915,7 +939,33 @@
"object",
"null"
],
- "description": "The conversation object"
+ "description": "The conversation object",
+ "properties": {
+ "message_limit": {
+ "type": "integer",
+ "description": "Maximum number of messages allowed in the conversation"
+ },
+ "message_limit_reached": {
+ "type": "boolean",
+ "description": "Whether the conversation has reached the message limit"
+ },
+ "message_creation_locked": {
+ "type": "boolean",
+ "description": "Whether new messages are currently blocked for the conversation"
+ },
+ "message_creation_lock_reason": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "enum": [
+ "message_limit",
+ "manual",
+ null
+ ],
+ "description": "Reason new message creation is blocked"
+ }
+ }
},
"attachment": {
"type": [
@@ -6296,6 +6346,30 @@
"type": "integer",
"description": "Timestamp of last activity"
},
+ "message_limit": {
+ "type": "integer",
+ "description": "Maximum number of messages allowed in the conversation"
+ },
+ "message_limit_reached": {
+ "type": "boolean",
+ "description": "Whether the conversation has reached the message limit"
+ },
+ "message_creation_locked": {
+ "type": "boolean",
+ "description": "Whether new messages are currently blocked for the conversation"
+ },
+ "message_creation_lock_reason": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "enum": [
+ "message_limit",
+ "manual",
+ null
+ ],
+ "description": "Reason new message creation is blocked"
+ },
"contact_inbox": {
"type": "object",
"description": "Contact inbox details",
diff --git a/swagger/tag_groups/platform_swagger.json b/swagger/tag_groups/platform_swagger.json
index 7e00568f2..5684b6bd3 100644
--- a/swagger/tag_groups/platform_swagger.json
+++ b/swagger/tag_groups/platform_swagger.json
@@ -1421,6 +1421,30 @@
"type": "boolean",
"description": "Whether the conversation can be replied to"
},
+ "message_limit": {
+ "type": "integer",
+ "description": "Maximum number of messages allowed in the conversation"
+ },
+ "message_limit_reached": {
+ "type": "boolean",
+ "description": "Whether the conversation has reached the message limit"
+ },
+ "message_creation_locked": {
+ "type": "boolean",
+ "description": "Whether new messages are currently blocked for the conversation"
+ },
+ "message_creation_lock_reason": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "enum": [
+ "message_limit",
+ "manual",
+ null
+ ],
+ "description": "Reason new message creation is blocked"
+ },
"contact_last_seen_at": {
"type": "number",
"description": "The last activity at of the contact"
@@ -1676,7 +1700,33 @@
"object",
"null"
],
- "description": "The conversation object"
+ "description": "The conversation object",
+ "properties": {
+ "message_limit": {
+ "type": "integer",
+ "description": "Maximum number of messages allowed in the conversation"
+ },
+ "message_limit_reached": {
+ "type": "boolean",
+ "description": "Whether the conversation has reached the message limit"
+ },
+ "message_creation_locked": {
+ "type": "boolean",
+ "description": "Whether new messages are currently blocked for the conversation"
+ },
+ "message_creation_lock_reason": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "enum": [
+ "message_limit",
+ "manual",
+ null
+ ],
+ "description": "Reason new message creation is blocked"
+ }
+ }
},
"attachment": {
"type": [
@@ -7057,6 +7107,30 @@
"type": "integer",
"description": "Timestamp of last activity"
},
+ "message_limit": {
+ "type": "integer",
+ "description": "Maximum number of messages allowed in the conversation"
+ },
+ "message_limit_reached": {
+ "type": "boolean",
+ "description": "Whether the conversation has reached the message limit"
+ },
+ "message_creation_locked": {
+ "type": "boolean",
+ "description": "Whether new messages are currently blocked for the conversation"
+ },
+ "message_creation_lock_reason": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "enum": [
+ "message_limit",
+ "manual",
+ null
+ ],
+ "description": "Reason new message creation is blocked"
+ },
"contact_inbox": {
"type": "object",
"description": "Contact inbox details",