Compare commits

...
Author SHA1 Message Date
Sony Mathew 48e7985b0f feat(conversations): add message creation limit lock 2026-07-02 00:28:31 +05:30
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.warn("Facebook authentication error for inbox: #{@inbox.id} with error: #{e.message}")
Rails.logger.error e Rails.logger.error e
@inbox.channel.authorization_error! @inbox.channel.authorization_error!
rescue CustomExceptions::ConversationMessageCreationLocked => e
Rails.logger.info("[FacebookMessageBuilder] Dropped message for inbox #{@inbox.id}: #{e.message}")
true
rescue StandardError => e rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
true true
@@ -14,6 +14,8 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
build_message build_message
end end
rescue CustomExceptions::ConversationMessageCreationLocked => e
Rails.logger.info("[InstagramMessageBuilder] Dropped message for inbox #{@inbox.id}: #{e.message}")
rescue StandardError => e rescue StandardError => e
handle_error(e) handle_error(e)
end end
@@ -9,6 +9,8 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
user = Current.user || @resource user = Current.user || @resource
mb = Messages::MessageBuilder.new(user, @conversation, params) mb = Messages::MessageBuilder.new(user, @conversation, params)
@message = mb.perform @message = mb.perform
rescue CustomExceptions::ConversationMessageCreationLocked => e
render_error_response(e)
rescue StandardError => e rescue StandardError => e
render_could_not_create_error(e.message) render_could_not_create_error(e.message)
end end
@@ -27,11 +29,14 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
def retry def retry
return if message.blank? return if message.blank?
raise CustomExceptions::ConversationMessageCreationLocked, @conversation if @conversation.message_creation_locked?
service = Messages::StatusUpdateService.new(message, 'sent') service = Messages::StatusUpdateService.new(message, 'sent')
service.perform service.perform
message.update!(content_attributes: {}) message.update!(content_attributes: {})
::SendReplyJob.perform_later(message.id) ::SendReplyJob.perform_later(message.id)
rescue CustomExceptions::ConversationMessageCreationLocked => e
render_error_response(e)
rescue StandardError => e rescue StandardError => e
render_could_not_create_error(e.message) render_could_not_create_error(e.message)
end end
@@ -9,6 +9,9 @@ class Api::V1::WebhooksController < ApplicationController
def twitter_events def twitter_events
twitter_consumer.consume twitter_consumer.consume
head :ok head :ok
rescue CustomExceptions::ConversationMessageCreationLocked => e
Rails.logger.info("Skipping Twitter webhook because message creation is locked (#{e.message})")
head :ok
rescue StandardError => e rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception ChatwootExceptionTracker.new(e).capture_exception
head :ok head :ok
@@ -3,6 +3,7 @@ module RequestExceptionHandler
included do included do
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
rescue_from CustomExceptions::ConversationMessageCreationLocked, with: :render_error_response
end end
private private
@@ -125,6 +125,10 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isMessageCreationLocked: {
type: Boolean,
default: false,
},
}, },
emits: [ emits: [
'toggleInsertArticle', 'toggleInsertArticle',
@@ -250,7 +254,16 @@ export default {
: this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP'); : this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP');
}, },
enableInsertArticleInReply() { 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() { isFetchingAppIntegrations() {
return this.uiFlags.isFetching; return this.uiFlags.isFetching;
@@ -340,7 +353,7 @@ export default {
@click="toggleMessageSignature" @click="toggleMessageSignature"
/> />
<NextButton <NextButton
v-if="showQuotedReplyToggle" v-if="showQuotedReplyButton"
v-tooltip.top-end="quotedReplyToggleTooltip" v-tooltip.top-end="quotedReplyToggleTooltip"
icon="i-ph-quotes" icon="i-ph-quotes"
:variant="quotedReplyEnabled ? 'solid' : 'faded'" :variant="quotedReplyEnabled ? 'solid' : 'faded'"
@@ -350,7 +363,7 @@ export default {
@click="$emit('toggleQuotedReply')" @click="$emit('toggleQuotedReply')"
/> />
<NextButton <NextButton
v-if="enableWhatsAppTemplates" v-if="showWhatsAppTemplateButton"
v-tooltip.top-end="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')" v-tooltip.top-end="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')"
icon="i-ph-whatsapp-logo" icon="i-ph-whatsapp-logo"
slate slate
@@ -359,7 +372,7 @@ export default {
@click="$emit('selectWhatsappTemplate')" @click="$emit('selectWhatsappTemplate')"
/> />
<NextButton <NextButton
v-if="enableContentTemplates" v-if="showContentTemplateButton"
v-tooltip.top-end="'Content Templates'" v-tooltip.top-end="'Content Templates'"
icon="i-ph-whatsapp-logo" icon="i-ph-whatsapp-logo"
slate slate
@@ -3,7 +3,7 @@ import { ref } from 'vue';
import CopilotEditor from 'dashboard/components/widgets/WootWriter/CopilotEditor.vue'; import CopilotEditor from 'dashboard/components/widgets/WootWriter/CopilotEditor.vue';
import CaptainLoader from 'dashboard/components/widgets/conversation/copilot/CaptainLoader.vue'; import CaptainLoader from 'dashboard/components/widgets/conversation/copilot/CaptainLoader.vue';
defineProps({ const props = defineProps({
showCopilotEditor: { showCopilotEditor: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -16,6 +16,10 @@ defineProps({
type: String, type: String,
default: '', default: '',
}, },
isMessageCreationLocked: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits([ const emit = defineEmits([
@@ -41,6 +45,8 @@ const clearEditorSelection = () => {
}; };
const onSend = () => { const onSend = () => {
if (props.isMessageCreationLocked) return;
emit('send', copilotEditorContent.value); emit('send', copilotEditorContent.value);
copilotEditorContent.value = ''; copilotEditorContent.value = '';
}; };
@@ -58,7 +64,9 @@ const onSend = () => {
@after-enter="emit('contentReady')" @after-enter="emit('contentReady')"
> >
<CopilotEditor <CopilotEditor
v-if="showCopilotEditor && !isGeneratingContent" v-if="
showCopilotEditor && !isGeneratingContent && !isMessageCreationLocked
"
key="copilot-editor" key="copilot-editor"
v-model="copilotEditorContent" v-model="copilotEditorContent"
class="copilot-editor" class="copilot-editor"
@@ -193,6 +193,19 @@ export default {
} }
return this.$t('CONVERSATION.CANNOT_REPLY'); 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() { replyWindowLink() {
if (this.isAFacebookInbox || this.isAnInstagramChannel) { if (this.isAFacebookInbox || this.isAnInstagramChannel) {
return REPLY_POLICY.FACEBOOK; return REPLY_POLICY.FACEBOOK;
@@ -435,6 +448,8 @@ export default {
}, },
async handleMessageRetry(message) { async handleMessageRetry(message) {
if (!message) return; if (!message) return;
if (this.currentChat.message_creation_locked) return;
const payload = useSnakeCase(message); const payload = useSnakeCase(message);
await this.$store.dispatch('sendMessageWithData', payload); await this.$store.dispatch('sendMessageWithData', payload);
}, },
@@ -455,7 +470,13 @@ export default {
> >
<div ref="topBannerRef"> <div ref="topBannerRef">
<Banner <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" color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg" class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="replyWindowBannerMessage" :banner-message="replyWindowBannerMessage"
@@ -201,6 +201,9 @@ export default {
!(this.isAWhatsAppChannel || this.isAPIInbox) !(this.isAWhatsAppChannel || this.isAPIInbox)
); );
}, },
isMessageCreationLocked() {
return !!this.currentChat?.message_creation_locked;
},
inboxId() { inboxId() {
return this.currentChat.inbox_id; return this.currentChat.inbox_id;
}, },
@@ -209,6 +212,9 @@ export default {
}, },
messagePlaceHolder() { messagePlaceHolder() {
if (this.isEditorDisabled) { if (this.isEditorDisabled) {
if (this.isMessageCreationLocked) {
return this.$t('CONVERSATION.FOOTER.MESSAGE_CREATION_LOCKED');
}
if (this.isAWhatsAppChannel) { if (this.isAWhatsAppChannel) {
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_WHATSAPP'); return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_WHATSAPP');
} }
@@ -326,7 +332,11 @@ export default {
return !this.isOnPrivateNote && this.showFileUpload; return !this.isOnPrivateNote && this.showFileUpload;
}, },
showAudioRecorderEditor() { showAudioRecorderEditor() {
return this.showAudioRecorder && this.isRecordingAudio; return (
!this.isMessageCreationLocked &&
this.showAudioRecorder &&
this.isRecordingAudio
);
}, },
isOnPrivateNote() { isOnPrivateNote() {
return this.replyType === REPLY_EDITOR_MODES.NOTE; return this.replyType === REPLY_EDITOR_MODES.NOTE;
@@ -439,6 +449,10 @@ export default {
return !this.showAudioRecorderEditor && !this.copilot.isActive.value; return !this.showAudioRecorderEditor && !this.copilot.isActive.value;
}, },
isEditorDisabled() { isEditorDisabled() {
if (this.isMessageCreationLocked) {
return true;
}
return ( return (
(this.isAWhatsAppChannel || this.isAPIInbox) && (this.isAWhatsAppChannel || this.isAPIInbox) &&
!this.isOnPrivateNote && !this.isOnPrivateNote &&
@@ -804,6 +818,8 @@ export default {
} }
}, },
sendMessageAsMultipleMessages(message, copilotAcceptedMessage = '') { sendMessageAsMultipleMessages(message, copilotAcceptedMessage = '') {
if (this.isMessageCreationLocked) return;
const messages = this.getMultipleMessagesPayload(message); const messages = this.getMultipleMessagesPayload(message);
messages.forEach(messagePayload => { messages.forEach(messagePayload => {
this.sendMessage( this.sendMessage(
@@ -888,6 +904,8 @@ export default {
editorMessage = '', editorMessage = '',
copilotAcceptedMessage = '' copilotAcceptedMessage = ''
) { ) {
if (this.isMessageCreationLocked) return;
try { try {
await this.$store.dispatch( await this.$store.dispatch(
'createPendingMessageAndSend', 'createPendingMessageAndSend',
@@ -907,6 +925,8 @@ export default {
} }
}, },
async onSendWhatsAppReply(messagePayload) { async onSendWhatsAppReply(messagePayload) {
if (this.isMessageCreationLocked) return;
this.sendMessage({ this.sendMessage({
conversationId: this.currentChat.id, conversationId: this.currentChat.id,
...messagePayload, ...messagePayload,
@@ -914,6 +934,8 @@ export default {
this.hideWhatsappTemplatesModal(); this.hideWhatsappTemplatesModal();
}, },
async onSendContentTemplateReply(messagePayload) { async onSendContentTemplateReply(messagePayload) {
if (this.isMessageCreationLocked) return;
this.sendMessage({ this.sendMessage({
conversationId: this.currentChat.id, conversationId: this.currentChat.id,
...messagePayload, ...messagePayload,
@@ -921,6 +943,8 @@ export default {
this.hideContentTemplatesModal(); this.hideContentTemplatesModal();
}, },
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) { setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
if (this.isMessageCreationLocked) return;
// Clear attachments when switching between private note and reply modes // Clear attachments when switching between private note and reply modes
// This is to prevent from breaking the upload rules // This is to prevent from breaking the upload rules
if (this.attachedFiles.length > 0) this.attachedFiles = []; if (this.attachedFiles.length > 0) this.attachedFiles = [];
@@ -943,6 +967,8 @@ export default {
this.onFocus(); this.onFocus();
}, },
executeCopilotAction(action, data) { executeCopilotAction(action, data) {
if (this.isMessageCreationLocked) return;
this.copilot.execute(action, data); this.copilot.execute(action, data);
}, },
clearMessage() { clearMessage() {
@@ -1235,6 +1261,8 @@ export default {
this.$nextTick(() => this.messageEditor?.focusEditorInputField()); this.$nextTick(() => this.messageEditor?.focusEditorInputField());
}, },
onSubmitCopilotReply() { onSubmitCopilotReply() {
if (this.isMessageCreationLocked) return;
const acceptedMessage = this.copilot.accept(); const acceptedMessage = this.copilot.accept();
this.message = acceptedMessage; this.message = acceptedMessage;
this.setCopilotAcceptedMessage(acceptedMessage); this.setCopilotAcceptedMessage(acceptedMessage);
@@ -1315,6 +1343,7 @@ export default {
v-if="copilot.isActive.value && !showAudioRecorderEditor" v-if="copilot.isActive.value && !showAudioRecorderEditor"
:show-copilot-editor="copilot.showEditor.value" :show-copilot-editor="copilot.showEditor.value"
:is-generating-content="copilot.isGenerating.value" :is-generating-content="copilot.isGenerating.value"
:is-message-creation-locked="isMessageCreationLocked"
:generated-content="copilot.generatedContent.value" :generated-content="copilot.generatedContent.value"
:placeholder="$t('CONVERSATION.FOOTER.COPILOT_MSG_INPUT')" :placeholder="$t('CONVERSATION.FOOTER.COPILOT_MSG_INPUT')"
@focus="onFocus" @focus="onFocus"
@@ -1395,7 +1424,9 @@ export default {
<CopilotReplyBottomPanel <CopilotReplyBottomPanel
v-if="copilot.isActive.value" v-if="copilot.isActive.value"
key="copilot-bottom-panel" key="copilot-bottom-panel"
:is-generating-content="copilot.isButtonDisabled.value" :is-generating-content="
copilot.isButtonDisabled.value || isMessageCreationLocked
"
@submit="onSubmitCopilotReply" @submit="onSubmitCopilotReply"
@cancel="copilot.reset" @cancel="copilot.reset"
/> />
@@ -1412,6 +1443,7 @@ export default {
:is-send-disabled="isReplyButtonDisabled" :is-send-disabled="isReplyButtonDisabled"
:is-note="isPrivate" :is-note="isPrivate"
:is-editor-disabled="isEditorDisabled" :is-editor-disabled="isEditorDisabled"
:is-message-creation-locked="isMessageCreationLocked"
:on-file-upload="onFileUpload" :on-file-upload="onFileUpload"
:on-send="onSendReply" :on-send="onSendReply"
:conversation-type="conversationType" :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_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", "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.", "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:", "REPLYING_TO": "You are replying to:",
"REMOVE_SELECTION": "Remove Selection", "REMOVE_SELECTION": "Remove Selection",
"DOWNLOAD": "Download", "DOWNLOAD": "Download",
@@ -213,6 +217,7 @@
"MESSAGING_RESTRICTED": "You cannot reply to this conversation", "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_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", "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.", "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", "COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else... Press enter to send follow-up",
"CLICK_HERE": "Click here to update", "CLICK_HERE": "Click here to update",
@@ -289,12 +289,14 @@ const actions = {
sendMessageWithData: async ({ commit }, pendingMessage) => { sendMessageWithData: async ({ commit }, pendingMessage) => {
const { conversation_id: conversationId, id } = pendingMessage; const { conversation_id: conversationId, id } = pendingMessage;
const shouldRetryMessage =
hasMessageFailedWithExternalError(pendingMessage);
try { try {
commit(types.ADD_MESSAGE, { commit(types.ADD_MESSAGE, {
...pendingMessage, ...pendingMessage,
status: MESSAGE_STATUS.PROGRESS, status: MESSAGE_STATUS.PROGRESS,
}); });
const response = hasMessageFailedWithExternalError(pendingMessage) const response = shouldRetryMessage
? await MessageApi.retry(conversationId, id) ? await MessageApi.retry(conversationId, id)
: await MessageApi.create(pendingMessage); : await MessageApi.create(pendingMessage);
commit(types.ADD_MESSAGE, { commit(types.ADD_MESSAGE, {
@@ -309,6 +311,31 @@ const actions = {
const errorMessage = error.response const errorMessage = error.response
? error.response.data.error ? error.response.data.error
: undefined; : 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, { commit(types.ADD_MESSAGE, {
...pendingMessage, ...pendingMessage,
meta: { meta: {
@@ -322,6 +349,12 @@ const actions = {
addMessage({ commit, rootGetters }, message) { addMessage({ commit, rootGetters }, message) {
commit(types.ADD_MESSAGE, 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) { if (message.message_type === MESSAGE_TYPE.INCOMING) {
commit(types.SET_CONVERSATION_CAN_REPLY, { commit(types.SET_CONVERSATION_CAN_REPLY, {
conversationId: message.conversation_id, conversationId: message.conversation_id,
@@ -29,6 +29,24 @@ const getConversationById = _state => conversationId => {
return _state.allConversations.find(c => c.id === 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 // mutations
export const mutations = { export const mutations = {
[types.SET_ALL_CONVERSATION](_state, conversationList) { [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) { [types.ADD_MESSAGE]({ allConversations, selectedChatId }, message) {
const { conversation_id: conversationId } = message; const { conversation_id: conversationId } = message;
const [chat] = getSelectedChatConversation({ const [chat] = getSelectedChatConversation({
@@ -216,17 +248,29 @@ export const mutations = {
const pendingMessageIndex = findPendingMessageIndex(chat, message); const pendingMessageIndex = findPendingMessageIndex(chat, message);
if (pendingMessageIndex !== -1) { if (pendingMessageIndex !== -1) {
chat.messages[pendingMessageIndex] = message; chat.messages[pendingMessageIndex] = message;
updateConversationMessageCreationLock(chat, message.conversation);
} else { } else {
chat.messages.push(message); chat.messages.push(message);
chat.timestamp = message.created_at; chat.timestamp = message.created_at;
const { conversation: { unread_count: unreadCount = 0 } = {} } = message; const { conversation: { unread_count: unreadCount = 0 } = {} } = message;
chat.unread_count = unreadCount; chat.unread_count = unreadCount;
updateConversationMessageCreationLock(chat, message.conversation);
if (selectedChatId === conversationId) { if (selectedChatId === conversationId) {
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE); 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) { [types.ADD_CONVERSATION](_state, conversation) {
const exists = _state.allConversations.some(c => c.id === conversation.id); const exists = _state.allConversations.some(c => c.id === conversation.id);
if (!exists) { if (!exists) {
@@ -288,6 +288,138 @@ describe('#actions', () => {
actions.addMessage({ commit }, message); actions.addMessage({ commit }, message);
expect(commit.mock.calls).toEqual([[types.ADD_MESSAGE, 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', () => { 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', () => { describe('#ADD_MESSAGE', () => {
it('does not add message to the store if conversation does not exist', () => { it('does not add message to the store if conversation does not exist', () => {
const state = { allConversations: [] }; const state = { allConversations: [] };
@@ -208,6 +248,32 @@ describe('#mutations', () => {
]); ]);
expect(emitter.emit).not.toHaveBeenCalled(); 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', () => { describe('#CHANGE_CONVERSATION_STATUS', () => {
@@ -54,6 +54,8 @@ export default {
UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY', UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY',
UPDATE_MESSAGE_CALL_STATUS: 'UPDATE_MESSAGE_CALL_STATUS', UPDATE_MESSAGE_CALL_STATUS: 'UPDATE_MESSAGE_CALL_STATUS',
SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES', SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES',
SET_CONVERSATION_MESSAGE_CREATION_LOCK:
'SET_CONVERSATION_MESSAGE_CREATION_LOCK',
SET_ALL_ATTACHMENTS: 'SET_ALL_ATTACHMENTS', SET_ALL_ATTACHMENTS: 'SET_ALL_ATTACHMENTS',
ADD_CONVERSATION_ATTACHMENTS: 'ADD_CONVERSATION_ATTACHMENTS', ADD_CONVERSATION_ATTACHMENTS: 'ADD_CONVERSATION_ATTACHMENTS',
+4
View File
@@ -5,4 +5,8 @@ class ApplicationJob < ActiveJob::Base
job.instance_variable_get(:@serialized_arguments) job.instance_variable_get(:@serialized_arguments)
} because of ActiveJob::DeserializationError (#{error.message})") } because of ActiveJob::DeserializationError (#{error.message})")
end end
discard_on CustomExceptions::ConversationMessageCreationLocked do |job, error|
Rails.logger.info("Skipping #{job.class} because message creation is locked (#{error.message})")
end
end end
@@ -69,6 +69,8 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
rescue Timeout::Error rescue Timeout::Error
mark_email_as_failed(inbound_mail.message_id) mark_email_as_failed(inbound_mail.message_id)
Rails.logger.error "[IMAP] Email processing timeout (#{email_processing_timeout}s): #{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 rescue StandardError => e
mark_email_as_failed(inbound_mail.message_id) mark_email_as_failed(inbound_mail.message_id)
Rails.logger.error "[IMAP] Failed to process email #{inbound_mail.message_id}: #{e.message}" 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 def email_processing_timeout
GlobalConfigService.load('EMAIL_PROCESSING_TIMEOUT_SECONDS', 60).to_i GlobalConfigService.load('EMAIL_PROCESSING_TIMEOUT_SECONDS', 60).to_i
end end
def log_message_creation_locked(inbound_mail, error)
Rails.logger.info "[IMAP] Dropped email #{inbound_mail.message_id}: #{error.message}"
end
end end
+4
View File
@@ -1,6 +1,10 @@
class ApplicationMailbox < ActionMailbox::Base class ApplicationMailbox < ActionMailbox::Base
include MailboxHelper 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 # Last part is the regex for the UUID
# Eg: email should be something like : reply+6bdc3f4d-0bec-4515-a284-5d916fdde489@domain.com # 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 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 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 Labelable
include LlmFormattable include LlmFormattable
include AssignmentHandler include AssignmentHandler
@@ -133,6 +137,58 @@ class Conversation < ApplicationRecord
Conversations::MessageWindowService.new(self).can_reply? Conversations::MessageWindowService.new(self).can_reply?
end 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 def language
additional_attributes&.dig('conversation_language') additional_attributes&.dig('conversation_language')
end end
@@ -243,6 +299,11 @@ class Conversation < ApplicationRecord
private 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 def execute_after_update_commit_callbacks
handle_resolved_status_change handle_resolved_status_change
notify_status_change notify_status_change
@@ -315,7 +376,10 @@ class Conversation < ApplicationRecord
def allowed_keys? def allowed_keys?
( (
previous_changes.keys.intersect?(list_of_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 end
+9
View File
@@ -63,6 +63,7 @@ class Message < ApplicationRecord
}.to_json.freeze }.to_json.freeze
before_validation :ensure_content_type before_validation :ensure_content_type
before_validation :ensure_message_creation_unlocked, on: :create
before_validation :prevent_message_flooding before_validation :prevent_message_flooding
before_save :ensure_processed_message_content before_save :ensure_processed_message_content
before_save :ensure_in_reply_to before_save :ensure_in_reply_to
@@ -160,6 +161,7 @@ class Message < ApplicationRecord
assignee_id: conversation.assignee_id, assignee_id: conversation.assignee_id,
unread_count: conversation.unread_incoming_messages.count, unread_count: conversation.unread_incoming_messages.count,
last_activity_at: conversation.last_activity_at.to_i, last_activity_at: conversation.last_activity_at.to_i,
**conversation.message_creation_lock_state,
contact_inbox: { source_id: conversation.contact_inbox.source_id } contact_inbox: { source_id: conversation.contact_inbox.source_id }
} }
end end
@@ -285,6 +287,13 @@ class Message < ApplicationRecord
private 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 def prevent_message_flooding
# Added this to cover the validation specs in messages # Added this to cover the validation specs in messages
# We can revisit and see if we can remove this later # 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, first_reply_created_at: first_reply_created_at,
priority: priority, priority: priority,
waiting_since: waiting_since.to_i, waiting_since: waiting_since.to_i,
**push_timestamps **push_timestamps.merge(message_creation_lock_data)
} }
end end
@@ -49,6 +49,10 @@ class Conversations::EventDataPresenter < SimpleDelegator
} }
end end
def message_creation_lock_data
message_creation_lock_state
end
def push_timestamps def push_timestamps
{ {
agent_last_seen_at: agent_last_seen_at.to_i, agent_last_seen_at: agent_last_seen_at.to_i,
@@ -12,6 +12,8 @@ class AutomationRules::ActionService < ActionService
action = action.with_indifferent_access action = action.with_indifferent_access
begin begin
send(action[:action_name], action[:action_params]) 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 rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @account).capture_exception ChatwootExceptionTracker.new(e, account: @account).capture_exception
end end
+21 -4
View File
@@ -3,7 +3,16 @@ class CsatSurveyService
def perform def perform
return unless should_send_csat_survey? 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? if whatsapp_channel? && template_available_and_approved?
send_whatsapp_template_survey send_whatsapp_template_survey
elsif inbox.twilio_whatsapp? && twilio_template_available_and_approved? elsif inbox.twilio_whatsapp? && twilio_template_available_and_approved?
@@ -15,10 +24,6 @@ class CsatSurveyService
end end
end end
private
delegate :inbox, :contact, to: :conversation
def should_send_csat_survey? def should_send_csat_survey?
conversation_allows_csat? && csat_enabled? && !csat_already_sent? && csat_allowed_by_survey_rules? conversation_allows_csat? && csat_enabled? && !csat_already_sent? && csat_allowed_by_survey_rules?
end end
@@ -115,6 +120,8 @@ class CsatSurveyService
message_id = inbox.channel.provider_service.send_template(phone_number, template_info, message) message_id = inbox.channel.provider_service.send_template(phone_number, template_info, message)
message.update!(source_id: message_id) if message_id.present? message.update!(source_id: message_id) if message_id.present?
rescue CustomExceptions::ConversationMessageCreationLocked => e
log_message_creation_locked(e)
rescue StandardError => e rescue StandardError => e
Rails.logger.error "Error sending WhatsApp CSAT template for conversation #{conversation.id}: #{e.message}" Rails.logger.error "Error sending WhatsApp CSAT template for conversation #{conversation.id}: #{e.message}"
end end
@@ -164,6 +171,8 @@ class CsatSurveyService
) )
message.update!(source_id: result[:message_id]) if result[:success] && result[:message_id].present? 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 rescue StandardError => e
Rails.logger.error "Error sending Twilio WhatsApp CSAT template for conversation #{conversation.id}: #{e.message}" Rails.logger.error "Error sending Twilio WhatsApp CSAT template for conversation #{conversation.id}: #{e.message}"
end end
@@ -178,4 +187,12 @@ class CsatSurveyService
} }
::Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params) if content ::Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params) if content
end 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 end
+2
View File
@@ -12,6 +12,8 @@ class Macros::ExecutionService < ActionService
action = action.with_indifferent_access action = action.with_indifferent_access
begin begin
send(action[:action_name], action[:action_params]) 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 rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @account).capture_exception ChatwootExceptionTracker.new(e, account: @account).capture_exception
end end
@@ -9,6 +9,8 @@ class MessageTemplates::Template::AutoResolve
else else
create_auto_resolve_not_sent_activity_message create_auto_resolve_not_sent_activity_message
end end
rescue CustomExceptions::ConversationMessageCreationLocked => e
Rails.logger.info("Skipping auto-resolve template because message creation is locked (#{e.message})")
end end
private private
@@ -5,6 +5,8 @@ class MessageTemplates::Template::CsatSurvey
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
conversation.messages.create!(csat_survey_message_params) conversation.messages.create!(csat_survey_message_params)
end end
rescue CustomExceptions::ConversationMessageCreationLocked => e
Rails.logger.info("Skipping CSAT survey template because message creation is locked (#{e.message})")
end end
private private
@@ -6,6 +6,9 @@ class MessageTemplates::Template::EmailCollect
conversation.messages.create!(ways_to_reach_you_message_params) conversation.messages.create!(ways_to_reach_you_message_params)
conversation.messages.create!(email_input_box_template_message_params) conversation.messages.create!(email_input_box_template_message_params)
end end
rescue CustomExceptions::ConversationMessageCreationLocked => e
Rails.logger.info("Skipping email collect template because message creation is locked (#{e.message})")
true
rescue StandardError => e rescue StandardError => e
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
true true
@@ -5,6 +5,9 @@ class MessageTemplates::Template::Greeting
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
conversation.messages.create!(greeting_message_params) conversation.messages.create!(greeting_message_params)
end end
rescue CustomExceptions::ConversationMessageCreationLocked => e
Rails.logger.info("Skipping greeting template because message creation is locked (#{e.message})")
true
rescue StandardError => e rescue StandardError => e
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
true true
@@ -13,6 +13,9 @@ class MessageTemplates::Template::OutOfOffice
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
conversation.messages.create!(out_of_office_message_params) conversation.messages.create!(out_of_office_message_params)
end end
rescue CustomExceptions::ConversationMessageCreationLocked => e
Rails.logger.info("Skipping out-of-office template because message creation is locked (#{e.message})")
true
rescue StandardError => e rescue StandardError => e
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
true true
@@ -42,6 +42,11 @@ json.additional_attributes conversation.additional_attributes
json.agent_last_seen_at conversation.agent_last_seen_at.to_i 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.assignee_last_seen_at conversation.assignee_last_seen_at.to_i
json.can_reply conversation.can_reply? 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.contact_last_seen_at conversation.contact_last_seen_at.to_i
json.custom_attributes conversation.custom_attributes json.custom_attributes conversation.custom_attributes
json.inbox_id conversation.inbox_id json.inbox_id conversation.inbox_id
@@ -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_call, only: %i[show accept reject terminate upload_recording]
before_action :set_conversation, only: :initiate before_action :set_conversation, only: :initiate
before_action :ensure_message_creation_unlocked, only: :initiate
before_action :ensure_calling_enabled, only: :initiate before_action :ensure_calling_enabled, only: :initiate
before_action :ensure_sdp_offer, only: :initiate before_action :ensure_sdp_offer, only: :initiate
before_action :ensure_contact_phone, 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')) render_could_not_create_error(I18n.t('errors.whatsapp.calls.contact_phone_required'))
end end
def ensure_message_creation_unlocked
return unless @conversation.message_creation_locked?
raise CustomExceptions::ConversationMessageCreationLocked, @conversation
end
def ensure_recording_present def ensure_recording_present
return if params[:recording].present? return if params[:recording].present?
@@ -73,8 +73,13 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
process_v1_handoff process_v1_handoff
elsif conversation_pending? elsif conversation_pending?
ActiveRecord::Base.transaction do create_messages_and_increment_usage
create_messages 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}") Rails.logger.info("[CAPTAIN][ResponseBuilderJob] Incrementing response usage for #{account.id}")
account.increment_response_usage account.increment_response_usage
end end
@@ -181,6 +186,10 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
additional_attributes: additional_attrs, additional_attributes: additional_attrs,
preserve_waiting_since: preserve_waiting_since 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 end
def handle_error(error) def handle_error(error)
@@ -100,26 +100,30 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
end end
def create_private_note(conversation, inbox, content) def create_private_note(conversation, inbox, content)
conversation.messages.create!( create_message_or_drop(conversation, 'private note') do
message_type: :outgoing, conversation.messages.create!(
private: true, message_type: :outgoing,
sender: inbox.captain_assistant, private: true,
account_id: conversation.account_id, sender: inbox.captain_assistant,
inbox_id: conversation.inbox_id, account_id: conversation.account_id,
content: content inbox_id: conversation.inbox_id,
) content: content
)
end
end end
def create_resolution_message(conversation, inbox) def create_resolution_message(conversation, inbox)
I18n.with_locale(inbox.account.locale) do I18n.with_locale(inbox.account.locale) do
resolution_message = inbox.captain_assistant.config['resolution_message'] resolution_message = inbox.captain_assistant.config['resolution_message']
conversation.messages.create!( create_message_or_drop(conversation, 'resolution message') do
message_type: :outgoing, conversation.messages.create!(
account_id: conversation.account_id, message_type: :outgoing,
inbox_id: conversation.inbox_id, account_id: conversation.account_id,
content: resolution_message.presence || I18n.t('conversations.activity.auto_resolution_message'), inbox_id: conversation.inbox_id,
sender: inbox.captain_assistant content: resolution_message.presence || I18n.t('conversations.activity.auto_resolution_message'),
) sender: inbox.captain_assistant
)
end
end end
end end
@@ -127,13 +131,23 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
handoff_message = inbox.captain_assistant.config['handoff_message'] handoff_message = inbox.captain_assistant.config['handoff_message']
return if handoff_message.blank? return if handoff_message.blank?
conversation.messages.create!( create_message_or_drop(conversation, 'handoff message') do
message_type: :outgoing, conversation.messages.create!(
sender: inbox.captain_assistant, message_type: :outgoing,
account_id: conversation.account_id, sender: inbox.captain_assistant,
inbox_id: conversation.inbox_id, account_id: conversation.account_id,
content: handoff_message, inbox_id: conversation.inbox_id,
preserve_waiting_since: true 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
end end
@@ -57,14 +57,20 @@ module Enterprise::MessageTemplates::HookExecutionService
return unless conversation.pending? return unless conversation.pending?
Rails.logger.info("Captain limit exceeded, performing handoff mid-conversation for conversation: #{conversation.id}") 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!( conversation.messages.create!(
message_type: :outgoing, message_type: :outgoing,
account_id: conversation.account.id, account_id: conversation.account.id,
inbox_id: conversation.inbox.id, inbox_id: conversation.inbox.id,
content: 'Transferring to another agent for further assistance.' content: 'Transferring to another agent for further assistance.'
) )
conversation.bot_handoff! rescue CustomExceptions::ConversationMessageCreationLocked => e
send_out_of_office_message_after_handoff Rails.logger.info("[CaptainHandoff] Dropped handoff message for conversation #{conversation.display_id}: #{e.message}")
end end
def send_out_of_office_message_after_handoff def send_out_of_office_message_after_handoff
@@ -9,7 +9,7 @@ class Captain::Tools::AddPrivateNoteTool < Captain::Tools::BasePublicTool
return 'Note content is required' if note.blank? return 'Note content is required' if note.blank?
log_tool_usage('add_private_note', { conversation_id: conversation.id, note_length: note.length }) 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' 'Private note added successfully'
end end
@@ -25,6 +25,9 @@ class Captain::Tools::AddPrivateNoteTool < Captain::Tools::BasePublicTool
content: note, content: note,
private: true private: true
) )
rescue CustomExceptions::ConversationMessageCreationLocked => e
Rails.logger.info("[CAPTAIN][AddPrivateNoteTool] Dropped private note for conversation #{conversation.display_id}: #{e.message}")
nil
end end
def permissions def permissions
+14 -8
View File
@@ -25,14 +25,7 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
def trigger_handoff(conversation, reason) def trigger_handoff(conversation, reason)
# post the reason as a private note # post the reason as a private note
conversation.messages.create!( create_private_note(conversation, reason)
message_type: :outgoing,
private: true,
sender: @assistant,
account: conversation.account,
inbox: conversation.inbox,
content: reason
)
# Trigger the bot handoff (sets status to open + dispatches events) # Trigger the bot handoff (sets status to open + dispatches events)
conversation.bot_handoff! conversation.bot_handoff!
@@ -49,6 +42,19 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation) ::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
end 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 # TODO: Future enhancement - Add team assignment capability
# This tool could be enhanced to: # This tool could be enhanced to:
# 1. Accept team_id parameter for routing to specific teams # 1. Accept team_id parameter for routing to specific teams
@@ -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
@@ -6,6 +6,8 @@ class Integrations::BotProcessorService
return unless should_run_processor?(message) return unless should_run_processor?(message)
process_content(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 rescue StandardError => e
ChatwootExceptionTracker.new(e, account: (hook&.account || agent_bot&.account)).capture_exception ChatwootExceptionTracker.new(e, account: (hook&.account || agent_bot&.account)).capture_exception
end end
@@ -2,6 +2,8 @@ class Integrations::Dyte::ProcessorService
pattr_initialize [:account!, :conversation!] pattr_initialize [:account!, :conversation!]
def create_a_meeting(agent) def create_a_meeting(agent)
raise CustomExceptions::ConversationMessageCreationLocked, conversation if conversation.message_creation_locked?
return missing_realtimekit_credentials_response if realtimekit_credentials_missing? return missing_realtimekit_credentials_response if realtimekit_credentials_missing?
title = I18n.t('integration_apps.dyte.meeting_name', agent_name: agent.available_name) title = I18n.t('integration_apps.dyte.meeting_name', agent_name: agent.available_name)
@@ -7,6 +7,9 @@ module Integrations::Slack::SlackMessageHelper
rescue Slack::Web::Api::Errors::MissingScope => e rescue Slack::Web::Api::Errors::MissingScope => e
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
disable_and_reauthorize disable_and_reauthorize
rescue CustomExceptions::ConversationMessageCreationLocked => e
Rails.logger.info("[SlackMessageHelper] Dropped message for conversation #{conversation.display_id}: #{e.message}")
success_response
end end
def handle_conversation def handle_conversation
+7
View File
@@ -14,4 +14,11 @@ module Limits
def self.conversation_message_per_minute_limit def self.conversation_message_per_minute_limit
ENV.fetch('CONVERSATION_MESSAGE_PER_MINUTE_LIMIT', '200').to_i ENV.fetch('CONVERSATION_MESSAGE_PER_MINUTE_LIMIT', '200').to_i
end 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 end
+24
View File
@@ -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
@@ -297,6 +297,23 @@ describe Messages::Facebook::MessageBuilder do
expect(facebook_channel.inbox.conversations.last.id).not_to eq(existing_conversation.id) expect(facebook_channel.inbox.conversations.last.id).not_to eq(existing_conversation.id)
expect(Conversation.count).to eq(inital_count + 1) expect(Conversation.count).to eq(inital_count + 1)
end 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 end
context 'when lock to single conversation is enabled' do context 'when lock to single conversation is enabled' do
@@ -101,6 +101,28 @@ describe Messages::Instagram::Messenger::MessageBuilder do
) )
end 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 it 'creates message with for reply with story id' do
messaging = instagram_story_reply_event[:entry][0]['messaging'][0] messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
sender_id = messaging['sender']['id'] sender_id = messaging['sender']['id']
@@ -82,6 +82,28 @@ RSpec.describe 'Conversation Messages API', type: :request do
expect(conversation.messages.last.attachments.first.file_type).to eq('image') expect(conversation.messages.last.attachments.first.file_type).to eq('image')
end 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 context 'when api inbox' do
let(:api_channel) { create(:channel_api, account: account) } let(:api_channel) { create(:channel_api, account: account) }
let(:api_inbox) { create(:inbox, channel: api_channel, 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.status).to eq('sent')
expect(message.reload.content_attributes['external_error']).to be_nil expect(message.reload.content_attributes['external_error']).to be_nil
end 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 end
context 'when the message id is invalid' do context 'when the message id is invalid' do
@@ -60,6 +60,27 @@ RSpec.describe 'Dyte Integration API', type: :request do
expect(conversation.display_id).to eq(response_body['conversation_id']) expect(conversation.display_id).to eq(response_body['conversation_id'])
expect(last_message.id).to eq(response_body['id']) expect(last_message.id).to eq(response_body['id'])
end 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 end
context 'when it is an agent with inbox access and the Dyte API is errored' do context 'when it is an agent with inbox access and the Dyte API is errored' do
@@ -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
@@ -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)') expect(json_response['message']).to eq('Content is too long (maximum is 150000 characters)')
end 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 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 } message_params = { content: 'hello world reply', timestamp: Time.current, reply_to: conversation.messages.first.id }
post api_v1_widget_messages_url, post api_v1_widget_messages_url,
@@ -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)') expect(json_response['message']).to eq('Content is too long (maximum is 150000 characters)')
end 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 it 'creates attachment message in conversation' do
file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png') 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", post "/public/api/v1/inboxes/#{api_channel.identifier}/contacts/#{contact_inbox.source_id}/conversations/#{conversation.display_id}/messages",
@@ -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') expect(Call.find_by(provider_call_id: 'wacid_outbound')).to have_attributes(direction: 'outgoing', status: 'ringing')
end 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 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(:initiate_call).and_raise(Voice::CallErrors::NoCallPermission)
allow(provider_service).to receive(:send_call_permission_request).and_return({ 'messages' => [{ 'id' => 'wamid.req_xyz' }] }) allow(provider_service).to receive(:send_call_permission_request).and_return({ 'messages' => [{ 'id' => 'wamid.req_xyz' }] })
@@ -19,6 +19,11 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
end end
context 'when captain_tasks is disabled' do 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 it 'resolves pending conversations inactive for over 1 hour' do
described_class.perform_now(inbox) described_class.perform_now(inbox)
@@ -59,6 +59,18 @@ RSpec.describe Captain::Tools::AddPrivateNoteTool, type: :model do
tool.perform(tool_context, note: 'This is a test note') tool.perform(tool_context, note: 'This is a test note')
end 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 end
context 'with blank note content' do context 'with blank note content' do
@@ -86,6 +86,22 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do
tool.perform(tool_context, reason: reason) tool.perform(tool_context, reason: reason)
end 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 end
context 'without reason provided' do context 'without reason provided' do
@@ -99,6 +99,20 @@ RSpec.describe MessageTemplates::HookExecutionService do
expect(conversation.reload.status).to eq('open') expect(conversation.reload.status).to eq('open')
end 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
end end
@@ -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
@@ -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
@@ -96,6 +96,18 @@ describe Integrations::Slack::IncomingMessageBuilder do
expect(conversation.messages.last.private).to be(true) expect(conversation.messages.last.private).to be(true)
end 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 it 'does not create message for invalid event type' do
messages_count = conversation.messages.count messages_count = conversation.messages.count
message_params[:type] = 'invalid_event_type' message_params[:type] = 'invalid_event_type'
+51
View File
@@ -104,6 +104,53 @@ RSpec.describe Conversation do
end end
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 describe '.after_update' do
let!(:account) { create(:account) } let!(:account) { create(:account) }
let!(:old_assignee) do let!(:old_assignee) do
@@ -620,6 +667,10 @@ RSpec.describe Conversation do
contact_inbox: conversation.contact_inbox, contact_inbox: conversation.contact_inbox,
timestamp: conversation.last_activity_at.to_i, timestamp: conversation.last_activity_at.to_i,
can_reply: true, 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', channel: 'Channel::WebWidget',
snoozed_until: conversation.snoozed_until, snoozed_until: conversation.snoozed_until,
custom_attributes: conversation.custom_attributes, custom_attributes: conversation.custom_attributes,
+45
View File
@@ -141,6 +141,10 @@ RSpec.describe Message do
source_id: message.conversation.contact_inbox.source_id source_id: message.conversation.contact_inbox.source_id
}, },
last_activity_at: message.conversation.last_activity_at.to_i, 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 unread_count: message.conversation.unread_incoming_messages.count
}, },
sentiment: {}, sentiment: {},
@@ -154,6 +158,47 @@ RSpec.describe Message do
end end
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 describe 'message create event' do
let!(:conversation) { create(:conversation) } let!(:conversation) { create(:conversation) }
@@ -24,6 +24,10 @@ RSpec.describe Conversations::EventDataPresenter do
status: conversation.status, status: conversation.status,
contact_inbox: conversation.contact_inbox, contact_inbox: conversation.contact_inbox,
can_reply: conversation.can_reply?, 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, channel: conversation.inbox.channel_type,
timestamp: conversation.last_activity_at.to_i, timestamp: conversation.last_activity_at.to_i,
snoozed_until: conversation.snoozed_until, snoozed_until: conversation.snoozed_until,
@@ -73,6 +73,18 @@ RSpec.describe AutomationRules::ActionService do
expect(message_builder).not_to receive(:perform) expect(message_builder).not_to receive(:perform)
described_class.new(rule, account, conversation).perform described_class.new(rule, account, conversation).perform
end 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 end
describe '#perform with send_email_to_team action' do describe '#perform with send_email_to_team action' do
+40
View File
@@ -28,6 +28,33 @@ describe CsatSurveyService do
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: conversation) expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: conversation)
expect(csat_template).to have_received(:perform) expect(csat_template).to have_received(:perform)
end 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 end
context 'when outside messaging window' do context 'when outside messaging window' do
@@ -217,6 +244,19 @@ describe CsatSurveyService do
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
expect(csat_message.content).to eq('Please rate this conversation') expect(csat_message.content).to eq('Please rate this conversation')
end 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 end
context 'when template is not available or not approved' do context 'when template is not available or not approved' do
@@ -45,6 +45,19 @@ RSpec.describe Macros::ExecutionService, type: :service do
service.perform service.perform
end 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 end
end end
@@ -29,5 +29,14 @@ describe MessageTemplates::Template::Greeting do
expect(conversation.messages.count).to eq(1) expect(conversation.messages.count).to eq(1)
expect(conversation.messages.last.content).to eq('Hello welcome to our board.') expect(conversation.messages.last.content).to eq('Hello welcome to our board.')
end 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
end end
@@ -80,6 +80,21 @@ properties:
last_activity_at: last_activity_at:
type: integer type: integer
description: Timestamp of last activity 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: contact_inbox:
type: object type: object
description: Contact inbox details description: Contact inbox details
+15 -1
View File
@@ -25,6 +25,21 @@ properties:
can_reply: can_reply:
type: boolean type: boolean
description: Whether the conversation can be replied to 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: contact_last_seen_at:
type: number type: number
description: The last activity at of the contact description: The last activity at of the contact
@@ -99,4 +114,3 @@ properties:
items: items:
type: object type: object
description: SLA event objects description: SLA event objects
+16
View File
@@ -82,6 +82,22 @@ properties:
- object - object
- 'null' - '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: attachment:
type: type:
- object - object
+75 -1
View File
@@ -9828,6 +9828,30 @@
"type": "boolean", "type": "boolean",
"description": "Whether the conversation can be replied to" "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": { "contact_last_seen_at": {
"type": "number", "type": "number",
"description": "The last activity at of the contact" "description": "The last activity at of the contact"
@@ -10083,7 +10107,33 @@
"object", "object",
"null" "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": { "attachment": {
"type": [ "type": [
@@ -15464,6 +15514,30 @@
"type": "integer", "type": "integer",
"description": "Timestamp of last activity" "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": { "contact_inbox": {
"type": "object", "type": "object",
"description": "Contact inbox details", "description": "Contact inbox details",
+75 -1
View File
@@ -8335,6 +8335,30 @@
"type": "boolean", "type": "boolean",
"description": "Whether the conversation can be replied to" "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": { "contact_last_seen_at": {
"type": "number", "type": "number",
"description": "The last activity at of the contact" "description": "The last activity at of the contact"
@@ -8590,7 +8614,33 @@
"object", "object",
"null" "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": { "attachment": {
"type": [ "type": [
@@ -13971,6 +14021,30 @@
"type": "integer", "type": "integer",
"description": "Timestamp of last activity" "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": { "contact_inbox": {
"type": "object", "type": "object",
"description": "Contact inbox details", "description": "Contact inbox details",
+75 -1
View File
@@ -1245,6 +1245,30 @@
"type": "boolean", "type": "boolean",
"description": "Whether the conversation can be replied to" "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": { "contact_last_seen_at": {
"type": "number", "type": "number",
"description": "The last activity at of the contact" "description": "The last activity at of the contact"
@@ -1500,7 +1524,33 @@
"object", "object",
"null" "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": { "attachment": {
"type": [ "type": [
@@ -6881,6 +6931,30 @@
"type": "integer", "type": "integer",
"description": "Timestamp of last activity" "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": { "contact_inbox": {
"type": "object", "type": "object",
"description": "Contact inbox details", "description": "Contact inbox details",
+75 -1
View File
@@ -660,6 +660,30 @@
"type": "boolean", "type": "boolean",
"description": "Whether the conversation can be replied to" "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": { "contact_last_seen_at": {
"type": "number", "type": "number",
"description": "The last activity at of the contact" "description": "The last activity at of the contact"
@@ -915,7 +939,33 @@
"object", "object",
"null" "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": { "attachment": {
"type": [ "type": [
@@ -6296,6 +6346,30 @@
"type": "integer", "type": "integer",
"description": "Timestamp of last activity" "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": { "contact_inbox": {
"type": "object", "type": "object",
"description": "Contact inbox details", "description": "Contact inbox details",
+75 -1
View File
@@ -1421,6 +1421,30 @@
"type": "boolean", "type": "boolean",
"description": "Whether the conversation can be replied to" "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": { "contact_last_seen_at": {
"type": "number", "type": "number",
"description": "The last activity at of the contact" "description": "The last activity at of the contact"
@@ -1676,7 +1700,33 @@
"object", "object",
"null" "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": { "attachment": {
"type": [ "type": [
@@ -7057,6 +7107,30 @@
"type": "integer", "type": "integer",
"description": "Timestamp of last activity" "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": { "contact_inbox": {
"type": "object", "type": "object",
"description": "Contact inbox details", "description": "Contact inbox details",