feat(conversations): add message creation limit lock

This commit is contained in:
Sony Mathew
2026-07-02 00:28:31 +05:30
parent 926a9d8a69
commit 48e7985b0f
73 changed files with 1691 additions and 58 deletions
@@ -29,6 +29,9 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
Rails.logger.warn("Facebook authentication error for inbox: #{@inbox.id} with error: #{e.message}")
Rails.logger.error e
@inbox.channel.authorization_error!
rescue CustomExceptions::ConversationMessageCreationLocked => e
Rails.logger.info("[FacebookMessageBuilder] Dropped message for inbox #{@inbox.id}: #{e.message}")
true
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
true
@@ -14,6 +14,8 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
ActiveRecord::Base.transaction do
build_message
end
rescue CustomExceptions::ConversationMessageCreationLocked => e
Rails.logger.info("[InstagramMessageBuilder] Dropped message for inbox #{@inbox.id}: #{e.message}")
rescue StandardError => e
handle_error(e)
end
@@ -9,6 +9,8 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
user = Current.user || @resource
mb = Messages::MessageBuilder.new(user, @conversation, params)
@message = mb.perform
rescue CustomExceptions::ConversationMessageCreationLocked => e
render_error_response(e)
rescue StandardError => e
render_could_not_create_error(e.message)
end
@@ -27,11 +29,14 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
def retry
return if message.blank?
raise CustomExceptions::ConversationMessageCreationLocked, @conversation if @conversation.message_creation_locked?
service = Messages::StatusUpdateService.new(message, 'sent')
service.perform
message.update!(content_attributes: {})
::SendReplyJob.perform_later(message.id)
rescue CustomExceptions::ConversationMessageCreationLocked => e
render_error_response(e)
rescue StandardError => e
render_could_not_create_error(e.message)
end
@@ -9,6 +9,9 @@ class Api::V1::WebhooksController < ApplicationController
def twitter_events
twitter_consumer.consume
head :ok
rescue CustomExceptions::ConversationMessageCreationLocked => e
Rails.logger.info("Skipping Twitter webhook because message creation is locked (#{e.message})")
head :ok
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
head :ok
@@ -3,6 +3,7 @@ module RequestExceptionHandler
included do
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
rescue_from CustomExceptions::ConversationMessageCreationLocked, with: :render_error_response
end
private
@@ -125,6 +125,10 @@ export default {
type: Boolean,
default: false,
},
isMessageCreationLocked: {
type: Boolean,
default: false,
},
},
emits: [
'toggleInsertArticle',
@@ -250,7 +254,16 @@ export default {
: this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP');
},
enableInsertArticleInReply() {
return this.portalSlug;
return !this.isMessageCreationLocked && this.portalSlug;
},
showQuotedReplyButton() {
return this.showQuotedReplyToggle && !this.isMessageCreationLocked;
},
showWhatsAppTemplateButton() {
return this.enableWhatsAppTemplates && !this.isMessageCreationLocked;
},
showContentTemplateButton() {
return this.enableContentTemplates && !this.isMessageCreationLocked;
},
isFetchingAppIntegrations() {
return this.uiFlags.isFetching;
@@ -340,7 +353,7 @@ export default {
@click="toggleMessageSignature"
/>
<NextButton
v-if="showQuotedReplyToggle"
v-if="showQuotedReplyButton"
v-tooltip.top-end="quotedReplyToggleTooltip"
icon="i-ph-quotes"
:variant="quotedReplyEnabled ? 'solid' : 'faded'"
@@ -350,7 +363,7 @@ export default {
@click="$emit('toggleQuotedReply')"
/>
<NextButton
v-if="enableWhatsAppTemplates"
v-if="showWhatsAppTemplateButton"
v-tooltip.top-end="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')"
icon="i-ph-whatsapp-logo"
slate
@@ -359,7 +372,7 @@ export default {
@click="$emit('selectWhatsappTemplate')"
/>
<NextButton
v-if="enableContentTemplates"
v-if="showContentTemplateButton"
v-tooltip.top-end="'Content Templates'"
icon="i-ph-whatsapp-logo"
slate
@@ -3,7 +3,7 @@ import { ref } from 'vue';
import CopilotEditor from 'dashboard/components/widgets/WootWriter/CopilotEditor.vue';
import CaptainLoader from 'dashboard/components/widgets/conversation/copilot/CaptainLoader.vue';
defineProps({
const props = defineProps({
showCopilotEditor: {
type: Boolean,
default: false,
@@ -16,6 +16,10 @@ defineProps({
type: String,
default: '',
},
isMessageCreationLocked: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
@@ -41,6 +45,8 @@ const clearEditorSelection = () => {
};
const onSend = () => {
if (props.isMessageCreationLocked) return;
emit('send', copilotEditorContent.value);
copilotEditorContent.value = '';
};
@@ -58,7 +64,9 @@ const onSend = () => {
@after-enter="emit('contentReady')"
>
<CopilotEditor
v-if="showCopilotEditor && !isGeneratingContent"
v-if="
showCopilotEditor && !isGeneratingContent && !isMessageCreationLocked
"
key="copilot-editor"
v-model="copilotEditorContent"
class="copilot-editor"
@@ -193,6 +193,19 @@ export default {
}
return this.$t('CONVERSATION.CANNOT_REPLY');
},
messageCreationLockBannerMessage() {
if (!this.currentChat?.message_creation_locked) {
return '';
}
if (this.currentChat.message_creation_lock_reason === 'message_limit') {
return this.$t('CONVERSATION.MESSAGE_CREATION_LOCK.MESSAGE_LIMIT', {
limit: this.currentChat.message_limit,
});
}
return this.$t('CONVERSATION.MESSAGE_CREATION_LOCK.MANUAL');
},
replyWindowLink() {
if (this.isAFacebookInbox || this.isAnInstagramChannel) {
return REPLY_POLICY.FACEBOOK;
@@ -435,6 +448,8 @@ export default {
},
async handleMessageRetry(message) {
if (!message) return;
if (this.currentChat.message_creation_locked) return;
const payload = useSnakeCase(message);
await this.$store.dispatch('sendMessageWithData', payload);
},
@@ -455,7 +470,13 @@ export default {
>
<div ref="topBannerRef">
<Banner
v-if="!currentChat.can_reply"
v-if="currentChat.message_creation_locked"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="messageCreationLockBannerMessage"
/>
<Banner
v-else-if="!currentChat.can_reply"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="replyWindowBannerMessage"
@@ -201,6 +201,9 @@ export default {
!(this.isAWhatsAppChannel || this.isAPIInbox)
);
},
isMessageCreationLocked() {
return !!this.currentChat?.message_creation_locked;
},
inboxId() {
return this.currentChat.inbox_id;
},
@@ -209,6 +212,9 @@ export default {
},
messagePlaceHolder() {
if (this.isEditorDisabled) {
if (this.isMessageCreationLocked) {
return this.$t('CONVERSATION.FOOTER.MESSAGE_CREATION_LOCKED');
}
if (this.isAWhatsAppChannel) {
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_WHATSAPP');
}
@@ -326,7 +332,11 @@ export default {
return !this.isOnPrivateNote && this.showFileUpload;
},
showAudioRecorderEditor() {
return this.showAudioRecorder && this.isRecordingAudio;
return (
!this.isMessageCreationLocked &&
this.showAudioRecorder &&
this.isRecordingAudio
);
},
isOnPrivateNote() {
return this.replyType === REPLY_EDITOR_MODES.NOTE;
@@ -439,6 +449,10 @@ export default {
return !this.showAudioRecorderEditor && !this.copilot.isActive.value;
},
isEditorDisabled() {
if (this.isMessageCreationLocked) {
return true;
}
return (
(this.isAWhatsAppChannel || this.isAPIInbox) &&
!this.isOnPrivateNote &&
@@ -804,6 +818,8 @@ export default {
}
},
sendMessageAsMultipleMessages(message, copilotAcceptedMessage = '') {
if (this.isMessageCreationLocked) return;
const messages = this.getMultipleMessagesPayload(message);
messages.forEach(messagePayload => {
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 {
<CopilotReplyBottomPanel
v-if="copilot.isActive.value"
key="copilot-bottom-panel"
:is-generating-content="copilot.isButtonDisabled.value"
:is-generating-content="
copilot.isButtonDisabled.value || isMessageCreationLocked
"
@submit="onSubmitCopilotReply"
@cancel="copilot.reset"
/>
@@ -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"
@@ -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:
'<button data-testid="copilot-editor" @click="$emit(\'send\')" />',
},
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
);
});
});
});
@@ -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 wont 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",
@@ -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,
@@ -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) {
@@ -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', () => {
@@ -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', () => {
@@ -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',
+4
View File
@@ -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
@@ -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
+4
View File
@@ -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
+65 -1
View File
@@ -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
+9
View File
@@ -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
@@ -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,
@@ -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
+21 -4
View File
@@ -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
+2
View File
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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