feat(conversations): add message creation limit lock
This commit is contained in:
@@ -29,6 +29,9 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
Rails.logger.warn("Facebook authentication error for inbox: #{@inbox.id} with error: #{e.message}")
|
||||
Rails.logger.error e
|
||||
@inbox.channel.authorization_error!
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info("[FacebookMessageBuilder] Dropped message for inbox #{@inbox.id}: #{e.message}")
|
||||
true
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
|
||||
true
|
||||
|
||||
@@ -14,6 +14,8 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
|
||||
ActiveRecord::Base.transaction do
|
||||
build_message
|
||||
end
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info("[InstagramMessageBuilder] Dropped message for inbox #{@inbox.id}: #{e.message}")
|
||||
rescue StandardError => e
|
||||
handle_error(e)
|
||||
end
|
||||
|
||||
@@ -9,6 +9,8 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
|
||||
user = Current.user || @resource
|
||||
mb = Messages::MessageBuilder.new(user, @conversation, params)
|
||||
@message = mb.perform
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
render_error_response(e)
|
||||
rescue StandardError => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
@@ -27,11 +29,14 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
|
||||
|
||||
def retry
|
||||
return if message.blank?
|
||||
raise CustomExceptions::ConversationMessageCreationLocked, @conversation if @conversation.message_creation_locked?
|
||||
|
||||
service = Messages::StatusUpdateService.new(message, 'sent')
|
||||
service.perform
|
||||
message.update!(content_attributes: {})
|
||||
::SendReplyJob.perform_later(message.id)
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
render_error_response(e)
|
||||
rescue StandardError => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
@@ -9,6 +9,9 @@ class Api::V1::WebhooksController < ApplicationController
|
||||
def twitter_events
|
||||
twitter_consumer.consume
|
||||
head :ok
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info("Skipping Twitter webhook because message creation is locked (#{e.message})")
|
||||
head :ok
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
head :ok
|
||||
|
||||
@@ -3,6 +3,7 @@ module RequestExceptionHandler
|
||||
|
||||
included do
|
||||
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
|
||||
rescue_from CustomExceptions::ConversationMessageCreationLocked, with: :render_error_response
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -125,6 +125,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isMessageCreationLocked: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'toggleInsertArticle',
|
||||
@@ -250,7 +254,16 @@ export default {
|
||||
: this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP');
|
||||
},
|
||||
enableInsertArticleInReply() {
|
||||
return this.portalSlug;
|
||||
return !this.isMessageCreationLocked && this.portalSlug;
|
||||
},
|
||||
showQuotedReplyButton() {
|
||||
return this.showQuotedReplyToggle && !this.isMessageCreationLocked;
|
||||
},
|
||||
showWhatsAppTemplateButton() {
|
||||
return this.enableWhatsAppTemplates && !this.isMessageCreationLocked;
|
||||
},
|
||||
showContentTemplateButton() {
|
||||
return this.enableContentTemplates && !this.isMessageCreationLocked;
|
||||
},
|
||||
isFetchingAppIntegrations() {
|
||||
return this.uiFlags.isFetching;
|
||||
@@ -340,7 +353,7 @@ export default {
|
||||
@click="toggleMessageSignature"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="showQuotedReplyToggle"
|
||||
v-if="showQuotedReplyButton"
|
||||
v-tooltip.top-end="quotedReplyToggleTooltip"
|
||||
icon="i-ph-quotes"
|
||||
:variant="quotedReplyEnabled ? 'solid' : 'faded'"
|
||||
@@ -350,7 +363,7 @@ export default {
|
||||
@click="$emit('toggleQuotedReply')"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="enableWhatsAppTemplates"
|
||||
v-if="showWhatsAppTemplateButton"
|
||||
v-tooltip.top-end="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')"
|
||||
icon="i-ph-whatsapp-logo"
|
||||
slate
|
||||
@@ -359,7 +372,7 @@ export default {
|
||||
@click="$emit('selectWhatsappTemplate')"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="enableContentTemplates"
|
||||
v-if="showContentTemplateButton"
|
||||
v-tooltip.top-end="'Content Templates'"
|
||||
icon="i-ph-whatsapp-logo"
|
||||
slate
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ref } from 'vue';
|
||||
import CopilotEditor from 'dashboard/components/widgets/WootWriter/CopilotEditor.vue';
|
||||
import CaptainLoader from 'dashboard/components/widgets/conversation/copilot/CaptainLoader.vue';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
showCopilotEditor: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -16,6 +16,10 @@ defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isMessageCreationLocked: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -41,6 +45,8 @@ const clearEditorSelection = () => {
|
||||
};
|
||||
|
||||
const onSend = () => {
|
||||
if (props.isMessageCreationLocked) return;
|
||||
|
||||
emit('send', copilotEditorContent.value);
|
||||
copilotEditorContent.value = '';
|
||||
};
|
||||
@@ -58,7 +64,9 @@ const onSend = () => {
|
||||
@after-enter="emit('contentReady')"
|
||||
>
|
||||
<CopilotEditor
|
||||
v-if="showCopilotEditor && !isGeneratingContent"
|
||||
v-if="
|
||||
showCopilotEditor && !isGeneratingContent && !isMessageCreationLocked
|
||||
"
|
||||
key="copilot-editor"
|
||||
v-model="copilotEditorContent"
|
||||
class="copilot-editor"
|
||||
|
||||
@@ -193,6 +193,19 @@ export default {
|
||||
}
|
||||
return this.$t('CONVERSATION.CANNOT_REPLY');
|
||||
},
|
||||
messageCreationLockBannerMessage() {
|
||||
if (!this.currentChat?.message_creation_locked) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (this.currentChat.message_creation_lock_reason === 'message_limit') {
|
||||
return this.$t('CONVERSATION.MESSAGE_CREATION_LOCK.MESSAGE_LIMIT', {
|
||||
limit: this.currentChat.message_limit,
|
||||
});
|
||||
}
|
||||
|
||||
return this.$t('CONVERSATION.MESSAGE_CREATION_LOCK.MANUAL');
|
||||
},
|
||||
replyWindowLink() {
|
||||
if (this.isAFacebookInbox || this.isAnInstagramChannel) {
|
||||
return REPLY_POLICY.FACEBOOK;
|
||||
@@ -435,6 +448,8 @@ export default {
|
||||
},
|
||||
async handleMessageRetry(message) {
|
||||
if (!message) return;
|
||||
if (this.currentChat.message_creation_locked) return;
|
||||
|
||||
const payload = useSnakeCase(message);
|
||||
await this.$store.dispatch('sendMessageWithData', payload);
|
||||
},
|
||||
@@ -455,7 +470,13 @@ export default {
|
||||
>
|
||||
<div ref="topBannerRef">
|
||||
<Banner
|
||||
v-if="!currentChat.can_reply"
|
||||
v-if="currentChat.message_creation_locked"
|
||||
color-scheme="alert"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="messageCreationLockBannerMessage"
|
||||
/>
|
||||
<Banner
|
||||
v-else-if="!currentChat.can_reply"
|
||||
color-scheme="alert"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="replyWindowBannerMessage"
|
||||
|
||||
@@ -201,6 +201,9 @@ export default {
|
||||
!(this.isAWhatsAppChannel || this.isAPIInbox)
|
||||
);
|
||||
},
|
||||
isMessageCreationLocked() {
|
||||
return !!this.currentChat?.message_creation_locked;
|
||||
},
|
||||
inboxId() {
|
||||
return this.currentChat.inbox_id;
|
||||
},
|
||||
@@ -209,6 +212,9 @@ export default {
|
||||
},
|
||||
messagePlaceHolder() {
|
||||
if (this.isEditorDisabled) {
|
||||
if (this.isMessageCreationLocked) {
|
||||
return this.$t('CONVERSATION.FOOTER.MESSAGE_CREATION_LOCKED');
|
||||
}
|
||||
if (this.isAWhatsAppChannel) {
|
||||
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_WHATSAPP');
|
||||
}
|
||||
@@ -326,7 +332,11 @@ export default {
|
||||
return !this.isOnPrivateNote && this.showFileUpload;
|
||||
},
|
||||
showAudioRecorderEditor() {
|
||||
return this.showAudioRecorder && this.isRecordingAudio;
|
||||
return (
|
||||
!this.isMessageCreationLocked &&
|
||||
this.showAudioRecorder &&
|
||||
this.isRecordingAudio
|
||||
);
|
||||
},
|
||||
isOnPrivateNote() {
|
||||
return this.replyType === REPLY_EDITOR_MODES.NOTE;
|
||||
@@ -439,6 +449,10 @@ export default {
|
||||
return !this.showAudioRecorderEditor && !this.copilot.isActive.value;
|
||||
},
|
||||
isEditorDisabled() {
|
||||
if (this.isMessageCreationLocked) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
(this.isAWhatsAppChannel || this.isAPIInbox) &&
|
||||
!this.isOnPrivateNote &&
|
||||
@@ -804,6 +818,8 @@ export default {
|
||||
}
|
||||
},
|
||||
sendMessageAsMultipleMessages(message, copilotAcceptedMessage = '') {
|
||||
if (this.isMessageCreationLocked) return;
|
||||
|
||||
const messages = this.getMultipleMessagesPayload(message);
|
||||
messages.forEach(messagePayload => {
|
||||
this.sendMessage(
|
||||
@@ -888,6 +904,8 @@ export default {
|
||||
editorMessage = '',
|
||||
copilotAcceptedMessage = ''
|
||||
) {
|
||||
if (this.isMessageCreationLocked) return;
|
||||
|
||||
try {
|
||||
await this.$store.dispatch(
|
||||
'createPendingMessageAndSend',
|
||||
@@ -907,6 +925,8 @@ export default {
|
||||
}
|
||||
},
|
||||
async onSendWhatsAppReply(messagePayload) {
|
||||
if (this.isMessageCreationLocked) return;
|
||||
|
||||
this.sendMessage({
|
||||
conversationId: this.currentChat.id,
|
||||
...messagePayload,
|
||||
@@ -914,6 +934,8 @@ export default {
|
||||
this.hideWhatsappTemplatesModal();
|
||||
},
|
||||
async onSendContentTemplateReply(messagePayload) {
|
||||
if (this.isMessageCreationLocked) return;
|
||||
|
||||
this.sendMessage({
|
||||
conversationId: this.currentChat.id,
|
||||
...messagePayload,
|
||||
@@ -921,6 +943,8 @@ export default {
|
||||
this.hideContentTemplatesModal();
|
||||
},
|
||||
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
|
||||
if (this.isMessageCreationLocked) return;
|
||||
|
||||
// Clear attachments when switching between private note and reply modes
|
||||
// This is to prevent from breaking the upload rules
|
||||
if (this.attachedFiles.length > 0) this.attachedFiles = [];
|
||||
@@ -943,6 +967,8 @@ export default {
|
||||
this.onFocus();
|
||||
},
|
||||
executeCopilotAction(action, data) {
|
||||
if (this.isMessageCreationLocked) return;
|
||||
|
||||
this.copilot.execute(action, data);
|
||||
},
|
||||
clearMessage() {
|
||||
@@ -1235,6 +1261,8 @@ export default {
|
||||
this.$nextTick(() => this.messageEditor?.focusEditorInputField());
|
||||
},
|
||||
onSubmitCopilotReply() {
|
||||
if (this.isMessageCreationLocked) return;
|
||||
|
||||
const acceptedMessage = this.copilot.accept();
|
||||
this.message = acceptedMessage;
|
||||
this.setCopilotAcceptedMessage(acceptedMessage);
|
||||
@@ -1315,6 +1343,7 @@ export default {
|
||||
v-if="copilot.isActive.value && !showAudioRecorderEditor"
|
||||
:show-copilot-editor="copilot.showEditor.value"
|
||||
:is-generating-content="copilot.isGenerating.value"
|
||||
:is-message-creation-locked="isMessageCreationLocked"
|
||||
:generated-content="copilot.generatedContent.value"
|
||||
:placeholder="$t('CONVERSATION.FOOTER.COPILOT_MSG_INPUT')"
|
||||
@focus="onFocus"
|
||||
@@ -1395,7 +1424,9 @@ export default {
|
||||
<CopilotReplyBottomPanel
|
||||
v-if="copilot.isActive.value"
|
||||
key="copilot-bottom-panel"
|
||||
:is-generating-content="copilot.isButtonDisabled.value"
|
||||
:is-generating-content="
|
||||
copilot.isButtonDisabled.value || isMessageCreationLocked
|
||||
"
|
||||
@submit="onSubmitCopilotReply"
|
||||
@cancel="copilot.reset"
|
||||
/>
|
||||
@@ -1412,6 +1443,7 @@ export default {
|
||||
:is-send-disabled="isReplyButtonDisabled"
|
||||
:is-note="isPrivate"
|
||||
:is-editor-disabled="isEditorDisabled"
|
||||
:is-message-creation-locked="isMessageCreationLocked"
|
||||
:on-file-upload="onFileUpload"
|
||||
:on-send="onSendReply"
|
||||
:conversation-type="conversationType"
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
|
||||
import CopilotEditorSection from '../CopilotEditorSection.vue';
|
||||
import MessagesView from '../MessagesView.vue';
|
||||
import ReplyBox from '../ReplyBox.vue';
|
||||
import ReplyBottomPanel from '../../WootWriter/ReplyBottomPanel.vue';
|
||||
|
||||
const mountCopilotEditorSection = props =>
|
||||
shallowMount(CopilotEditorSection, {
|
||||
props: {
|
||||
showCopilotEditor: true,
|
||||
isGeneratingContent: false,
|
||||
generatedContent: 'Suggested reply',
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Transition: false,
|
||||
CopilotEditor: {
|
||||
template:
|
||||
'<button data-testid="copilot-editor" @click="$emit(\'send\')" />',
|
||||
},
|
||||
CaptainLoader: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('conversation message creation lock UI', () => {
|
||||
describe('MessagesView', () => {
|
||||
it('returns the message-limit banner copy with the configured limit', () => {
|
||||
const $t = vi.fn((key, params) => `${key}:${params.limit}`);
|
||||
const message =
|
||||
MessagesView.computed.messageCreationLockBannerMessage.call({
|
||||
currentChat: {
|
||||
message_creation_locked: true,
|
||||
message_creation_lock_reason: 'message_limit',
|
||||
message_limit: 10000,
|
||||
},
|
||||
$t,
|
||||
});
|
||||
|
||||
expect(message).toBe(
|
||||
'CONVERSATION.MESSAGE_CREATION_LOCK.MESSAGE_LIMIT:10000'
|
||||
);
|
||||
expect($t).toHaveBeenCalledWith(
|
||||
'CONVERSATION.MESSAGE_CREATION_LOCK.MESSAGE_LIMIT',
|
||||
{ limit: 10000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the manual lock banner copy', () => {
|
||||
const $t = vi.fn(key => key);
|
||||
const message =
|
||||
MessagesView.computed.messageCreationLockBannerMessage.call({
|
||||
currentChat: {
|
||||
message_creation_locked: true,
|
||||
message_creation_lock_reason: 'manual',
|
||||
},
|
||||
$t,
|
||||
});
|
||||
|
||||
expect(message).toBe('CONVERSATION.MESSAGE_CREATION_LOCK.MANUAL');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReplyBox', () => {
|
||||
it('disables the editor when message creation is locked', () => {
|
||||
const isDisabled = ReplyBox.computed.isEditorDisabled.call({
|
||||
isMessageCreationLocked: true,
|
||||
isAWhatsAppChannel: false,
|
||||
isAPIInbox: false,
|
||||
isOnPrivateNote: false,
|
||||
currentChat: { can_reply: true },
|
||||
});
|
||||
|
||||
expect(isDisabled).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the locked placeholder when message creation is locked', () => {
|
||||
const placeholder = ReplyBox.computed.messagePlaceHolder.call({
|
||||
isEditorDisabled: true,
|
||||
isMessageCreationLocked: true,
|
||||
$t: key => key,
|
||||
});
|
||||
|
||||
expect(placeholder).toBe('CONVERSATION.FOOTER.MESSAGE_CREATION_LOCKED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReplyBottomPanel', () => {
|
||||
it('keeps template actions available when only the editor is disabled', () => {
|
||||
const showWhatsAppTemplateButton =
|
||||
ReplyBottomPanel.computed.showWhatsAppTemplateButton.call({
|
||||
enableWhatsAppTemplates: true,
|
||||
isMessageCreationLocked: false,
|
||||
});
|
||||
const showContentTemplateButton =
|
||||
ReplyBottomPanel.computed.showContentTemplateButton.call({
|
||||
enableContentTemplates: true,
|
||||
isMessageCreationLocked: false,
|
||||
});
|
||||
|
||||
expect(showWhatsAppTemplateButton).toBe(true);
|
||||
expect(showContentTemplateButton).toBe(true);
|
||||
});
|
||||
|
||||
it('hides template actions when message creation is locked', () => {
|
||||
const showWhatsAppTemplateButton =
|
||||
ReplyBottomPanel.computed.showWhatsAppTemplateButton.call({
|
||||
enableWhatsAppTemplates: true,
|
||||
isMessageCreationLocked: true,
|
||||
});
|
||||
const showContentTemplateButton =
|
||||
ReplyBottomPanel.computed.showContentTemplateButton.call({
|
||||
enableContentTemplates: true,
|
||||
isMessageCreationLocked: true,
|
||||
});
|
||||
|
||||
expect(showWhatsAppTemplateButton).toBe(false);
|
||||
expect(showContentTemplateButton).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CopilotEditorSection', () => {
|
||||
it('keeps the follow-up editor available when message creation is unlocked', async () => {
|
||||
const wrapper = mountCopilotEditorSection({
|
||||
isMessageCreationLocked: false,
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.find('[data-testid="copilot-editor"]').exists()).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('hides the follow-up editor when message creation is locked', async () => {
|
||||
const wrapper = mountCopilotEditorSection({
|
||||
isMessageCreationLocked: true,
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.find('[data-testid="copilot-editor"]').exists()).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,10 @@
|
||||
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
|
||||
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
|
||||
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You won’t be able to send messages from this conversation anymore.",
|
||||
"MESSAGE_CREATION_LOCK": {
|
||||
"MESSAGE_LIMIT": "This conversation has reached the {limit} message limit. We will drop all messages after this limit.",
|
||||
"MANUAL": "This conversation is locked. We will drop all new messages until it is unlocked."
|
||||
},
|
||||
"REPLYING_TO": "You are replying to:",
|
||||
"REMOVE_SELECTION": "Remove Selection",
|
||||
"DOWNLOAD": "Download",
|
||||
@@ -213,6 +217,7 @@
|
||||
"MESSAGING_RESTRICTED": "You cannot reply to this conversation",
|
||||
"MESSAGING_RESTRICTED_WHATSAPP": "You can only reply using a template message due to 24-hour message window restriction",
|
||||
"MESSAGING_RESTRICTED_API": "You can only reply using a template message due to message window restriction",
|
||||
"MESSAGE_CREATION_LOCKED": "Message creation is locked for this conversation",
|
||||
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
|
||||
"COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else... Press enter to send follow-up",
|
||||
"CLICK_HERE": "Click here to update",
|
||||
|
||||
@@ -289,12 +289,14 @@ const actions = {
|
||||
|
||||
sendMessageWithData: async ({ commit }, pendingMessage) => {
|
||||
const { conversation_id: conversationId, id } = pendingMessage;
|
||||
const shouldRetryMessage =
|
||||
hasMessageFailedWithExternalError(pendingMessage);
|
||||
try {
|
||||
commit(types.ADD_MESSAGE, {
|
||||
...pendingMessage,
|
||||
status: MESSAGE_STATUS.PROGRESS,
|
||||
});
|
||||
const response = hasMessageFailedWithExternalError(pendingMessage)
|
||||
const response = shouldRetryMessage
|
||||
? await MessageApi.retry(conversationId, id)
|
||||
: await MessageApi.create(pendingMessage);
|
||||
commit(types.ADD_MESSAGE, {
|
||||
@@ -309,6 +311,31 @@ const actions = {
|
||||
const errorMessage = error.response
|
||||
? error.response.data.error
|
||||
: undefined;
|
||||
const errorResponse = error.response?.data || {};
|
||||
if (errorResponse.message_creation_locked) {
|
||||
if (shouldRetryMessage) {
|
||||
commit(types.ADD_MESSAGE, {
|
||||
...pendingMessage,
|
||||
meta: {
|
||||
...(pendingMessage.meta || {}),
|
||||
error: errorResponse.error,
|
||||
},
|
||||
status: MESSAGE_STATUS.FAILED,
|
||||
});
|
||||
} else {
|
||||
commit(types.DELETE_MESSAGE, pendingMessage);
|
||||
}
|
||||
commit(types.SET_CONVERSATION_MESSAGE_CREATION_LOCK, {
|
||||
conversationId,
|
||||
message_limit: errorResponse.message_limit,
|
||||
message_limit_reached: errorResponse.message_limit_reached,
|
||||
message_creation_locked: errorResponse.message_creation_locked,
|
||||
message_creation_lock_reason:
|
||||
errorResponse.message_creation_lock_reason,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
commit(types.ADD_MESSAGE, {
|
||||
...pendingMessage,
|
||||
meta: {
|
||||
@@ -322,6 +349,12 @@ const actions = {
|
||||
|
||||
addMessage({ commit, rootGetters }, message) {
|
||||
commit(types.ADD_MESSAGE, message);
|
||||
if (message.conversation) {
|
||||
commit(types.SET_CONVERSATION_MESSAGE_CREATION_LOCK, {
|
||||
conversationId: message.conversation_id,
|
||||
...message.conversation,
|
||||
});
|
||||
}
|
||||
if (message.message_type === MESSAGE_TYPE.INCOMING) {
|
||||
commit(types.SET_CONVERSATION_CAN_REPLY, {
|
||||
conversationId: message.conversation_id,
|
||||
|
||||
@@ -29,6 +29,24 @@ const getConversationById = _state => conversationId => {
|
||||
return _state.allConversations.find(c => c.id === conversationId);
|
||||
};
|
||||
|
||||
const MESSAGE_CREATION_LOCK_FIELDS = [
|
||||
'message_limit',
|
||||
'message_limit_reached',
|
||||
'message_creation_locked',
|
||||
'message_creation_lock_reason',
|
||||
];
|
||||
|
||||
const updateConversationMessageCreationLock = (
|
||||
conversation,
|
||||
lockState = {}
|
||||
) => {
|
||||
MESSAGE_CREATION_LOCK_FIELDS.forEach(field => {
|
||||
if (Object.prototype.hasOwnProperty.call(lockState, field)) {
|
||||
conversation[field] = lockState[field];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// mutations
|
||||
export const mutations = {
|
||||
[types.SET_ALL_CONVERSATION](_state, conversationList) {
|
||||
@@ -205,6 +223,20 @@ export const mutations = {
|
||||
});
|
||||
},
|
||||
|
||||
[types.DELETE_MESSAGE]({ allConversations }, message) {
|
||||
const { conversation_id: conversationId } = message;
|
||||
const [chat] = getSelectedChatConversation({
|
||||
allConversations,
|
||||
selectedChatId: conversationId,
|
||||
});
|
||||
if (!chat) return;
|
||||
|
||||
const pendingMessageIndex = findPendingMessageIndex(chat, message);
|
||||
if (pendingMessageIndex !== -1) {
|
||||
chat.messages.splice(pendingMessageIndex, 1);
|
||||
}
|
||||
},
|
||||
|
||||
[types.ADD_MESSAGE]({ allConversations, selectedChatId }, message) {
|
||||
const { conversation_id: conversationId } = message;
|
||||
const [chat] = getSelectedChatConversation({
|
||||
@@ -216,17 +248,29 @@ export const mutations = {
|
||||
const pendingMessageIndex = findPendingMessageIndex(chat, message);
|
||||
if (pendingMessageIndex !== -1) {
|
||||
chat.messages[pendingMessageIndex] = message;
|
||||
updateConversationMessageCreationLock(chat, message.conversation);
|
||||
} else {
|
||||
chat.messages.push(message);
|
||||
chat.timestamp = message.created_at;
|
||||
const { conversation: { unread_count: unreadCount = 0 } = {} } = message;
|
||||
chat.unread_count = unreadCount;
|
||||
updateConversationMessageCreationLock(chat, message.conversation);
|
||||
if (selectedChatId === conversationId) {
|
||||
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
[types.SET_CONVERSATION_MESSAGE_CREATION_LOCK](
|
||||
_state,
|
||||
{ conversationId, ...lockState }
|
||||
) {
|
||||
const chat = getConversationById(_state)(conversationId);
|
||||
if (chat) {
|
||||
updateConversationMessageCreationLock(chat, lockState);
|
||||
}
|
||||
},
|
||||
|
||||
[types.ADD_CONVERSATION](_state, conversation) {
|
||||
const exists = _state.allConversations.some(c => c.id === conversation.id);
|
||||
if (!exists) {
|
||||
|
||||
@@ -288,6 +288,138 @@ describe('#actions', () => {
|
||||
actions.addMessage({ commit }, message);
|
||||
expect(commit.mock.calls).toEqual([[types.ADD_MESSAGE, message]]);
|
||||
});
|
||||
|
||||
it('syncs message creation lock metadata from message payload', () => {
|
||||
const message = {
|
||||
id: 1,
|
||||
message_type: 1,
|
||||
conversation_id: 1,
|
||||
conversation: {
|
||||
message_limit: 10000,
|
||||
message_limit_reached: true,
|
||||
message_creation_locked: true,
|
||||
message_creation_lock_reason: 'message_limit',
|
||||
},
|
||||
};
|
||||
actions.addMessage({ commit }, message);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.ADD_MESSAGE, message],
|
||||
[
|
||||
types.SET_CONVERSATION_MESSAGE_CREATION_LOCK,
|
||||
{
|
||||
conversationId: 1,
|
||||
...message.conversation,
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#sendMessageWithData', () => {
|
||||
it('removes optimistic message and updates lock metadata when creation is locked', async () => {
|
||||
const localCommit = vi.fn();
|
||||
const pendingMessage = {
|
||||
id: 'temp-1',
|
||||
conversation_id: 1,
|
||||
message: 'Locked message',
|
||||
};
|
||||
const error = {
|
||||
response: {
|
||||
data: {
|
||||
error: 'This conversation has reached the 10000 message limit.',
|
||||
message_limit: 10000,
|
||||
message_limit_reached: true,
|
||||
message_creation_locked: true,
|
||||
message_creation_lock_reason: 'message_limit',
|
||||
},
|
||||
},
|
||||
};
|
||||
axios.mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
actions.sendMessageWithData({ commit: localCommit }, pendingMessage)
|
||||
).rejects.toBe(error);
|
||||
|
||||
expect(localCommit.mock.calls).toEqual([
|
||||
[
|
||||
types.ADD_MESSAGE,
|
||||
{
|
||||
...pendingMessage,
|
||||
status: 'progress',
|
||||
},
|
||||
],
|
||||
[types.DELETE_MESSAGE, pendingMessage],
|
||||
[
|
||||
types.SET_CONVERSATION_MESSAGE_CREATION_LOCK,
|
||||
{
|
||||
conversationId: 1,
|
||||
message_limit: 10000,
|
||||
message_limit_reached: true,
|
||||
message_creation_locked: true,
|
||||
message_creation_lock_reason: 'message_limit',
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps an existing failed message when retry is locked', async () => {
|
||||
const localCommit = vi.fn();
|
||||
const pendingMessage = {
|
||||
id: 42,
|
||||
conversation_id: 1,
|
||||
message: 'Retry message',
|
||||
status: 'failed',
|
||||
content_attributes: {
|
||||
external_error: 'Provider rejected the message',
|
||||
},
|
||||
};
|
||||
const error = {
|
||||
response: {
|
||||
data: {
|
||||
error: 'This conversation has reached the 10000 message limit.',
|
||||
message_limit: 10000,
|
||||
message_limit_reached: true,
|
||||
message_creation_locked: true,
|
||||
message_creation_lock_reason: 'message_limit',
|
||||
},
|
||||
},
|
||||
};
|
||||
axios.post.mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
actions.sendMessageWithData({ commit: localCommit }, pendingMessage)
|
||||
).rejects.toBe(error);
|
||||
|
||||
expect(localCommit.mock.calls).toEqual([
|
||||
[
|
||||
types.ADD_MESSAGE,
|
||||
{
|
||||
...pendingMessage,
|
||||
status: 'progress',
|
||||
},
|
||||
],
|
||||
[
|
||||
types.ADD_MESSAGE,
|
||||
{
|
||||
...pendingMessage,
|
||||
meta: {
|
||||
error: error.response.data.error,
|
||||
},
|
||||
status: 'failed',
|
||||
},
|
||||
],
|
||||
[
|
||||
types.SET_CONVERSATION_MESSAGE_CREATION_LOCK,
|
||||
{
|
||||
conversationId: 1,
|
||||
message_limit: 10000,
|
||||
message_limit_reached: true,
|
||||
message_creation_locked: true,
|
||||
message_creation_lock_reason: 'message_limit',
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#markMessagesRead', () => {
|
||||
|
||||
@@ -109,6 +109,46 @@ describe('#mutations', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#SET_CONVERSATION_MESSAGE_CREATION_LOCK', () => {
|
||||
it('sets message creation lock metadata', () => {
|
||||
const state = { allConversations: [{ id: 1, messages: [] }] };
|
||||
mutations[types.SET_CONVERSATION_MESSAGE_CREATION_LOCK](state, {
|
||||
conversationId: 1,
|
||||
message_limit: 10000,
|
||||
message_limit_reached: true,
|
||||
message_creation_locked: true,
|
||||
message_creation_lock_reason: 'message_limit',
|
||||
});
|
||||
|
||||
expect(state.allConversations[0]).toMatchObject({
|
||||
message_limit: 10000,
|
||||
message_limit_reached: true,
|
||||
message_creation_locked: true,
|
||||
message_creation_lock_reason: 'message_limit',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#DELETE_MESSAGE', () => {
|
||||
it('removes a pending message from the conversation', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{
|
||||
id: 1,
|
||||
messages: [{ id: 'temp-1', echo_id: 'temp-1' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mutations[types.DELETE_MESSAGE](state, {
|
||||
conversation_id: 1,
|
||||
id: 'temp-1',
|
||||
});
|
||||
|
||||
expect(state.allConversations[0].messages).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#ADD_MESSAGE', () => {
|
||||
it('does not add message to the store if conversation does not exist', () => {
|
||||
const state = { allConversations: [] };
|
||||
@@ -208,6 +248,32 @@ describe('#mutations', () => {
|
||||
]);
|
||||
expect(emitter.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates conversation lock metadata from message payload', () => {
|
||||
const state = {
|
||||
allConversations: [{ id: 1, messages: [] }],
|
||||
selectedChatId: 1,
|
||||
};
|
||||
|
||||
mutations[types.ADD_MESSAGE](state, {
|
||||
conversation_id: 1,
|
||||
content: 'Test message',
|
||||
created_at: 1602256198,
|
||||
conversation: {
|
||||
message_limit: 10000,
|
||||
message_limit_reached: true,
|
||||
message_creation_locked: true,
|
||||
message_creation_lock_reason: 'message_limit',
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.allConversations[0]).toMatchObject({
|
||||
message_limit: 10000,
|
||||
message_limit_reached: true,
|
||||
message_creation_locked: true,
|
||||
message_creation_lock_reason: 'message_limit',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#CHANGE_CONVERSATION_STATUS', () => {
|
||||
|
||||
@@ -54,6 +54,8 @@ export default {
|
||||
UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY',
|
||||
UPDATE_MESSAGE_CALL_STATUS: 'UPDATE_MESSAGE_CALL_STATUS',
|
||||
SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES',
|
||||
SET_CONVERSATION_MESSAGE_CREATION_LOCK:
|
||||
'SET_CONVERSATION_MESSAGE_CREATION_LOCK',
|
||||
|
||||
SET_ALL_ATTACHMENTS: 'SET_ALL_ATTACHMENTS',
|
||||
ADD_CONVERSATION_ATTACHMENTS: 'ADD_CONVERSATION_ATTACHMENTS',
|
||||
|
||||
@@ -5,4 +5,8 @@ class ApplicationJob < ActiveJob::Base
|
||||
job.instance_variable_get(:@serialized_arguments)
|
||||
} because of ActiveJob::DeserializationError (#{error.message})")
|
||||
end
|
||||
|
||||
discard_on CustomExceptions::ConversationMessageCreationLocked do |job, error|
|
||||
Rails.logger.info("Skipping #{job.class} because message creation is locked (#{error.message})")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -69,6 +69,8 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
|
||||
rescue Timeout::Error
|
||||
mark_email_as_failed(inbound_mail.message_id)
|
||||
Rails.logger.error "[IMAP] Email processing timeout (#{email_processing_timeout}s): #{inbound_mail.message_id}"
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
log_message_creation_locked(inbound_mail, e)
|
||||
rescue StandardError => e
|
||||
mark_email_as_failed(inbound_mail.message_id)
|
||||
Rails.logger.error "[IMAP] Failed to process email #{inbound_mail.message_id}: #{e.message}"
|
||||
@@ -79,4 +81,8 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
|
||||
def email_processing_timeout
|
||||
GlobalConfigService.load('EMAIL_PROCESSING_TIMEOUT_SECONDS', 60).to_i
|
||||
end
|
||||
|
||||
def log_message_creation_locked(inbound_mail, error)
|
||||
Rails.logger.info "[IMAP] Dropped email #{inbound_mail.message_id}: #{error.message}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
class ApplicationMailbox < ActionMailbox::Base
|
||||
include MailboxHelper
|
||||
|
||||
rescue_from CustomExceptions::ConversationMessageCreationLocked do |error|
|
||||
Rails.logger.info("Skipping inbound email because message creation is locked (#{error.message})")
|
||||
end
|
||||
|
||||
# Last part is the regex for the UUID
|
||||
# Eg: email should be something like : reply+6bdc3f4d-0bec-4515-a284-5d916fdde489@domain.com
|
||||
REPLY_EMAIL_UUID_PATTERN = /^reply\+([0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12})$/i
|
||||
|
||||
@@ -52,6 +52,10 @@
|
||||
#
|
||||
|
||||
class Conversation < ApplicationRecord
|
||||
MESSAGE_CREATION_LOCK_KEY = 'message_creation_lock'.freeze
|
||||
MESSAGE_CREATION_LOCK_REASON_MANUAL = 'manual'.freeze
|
||||
MESSAGE_CREATION_LOCK_REASON_MESSAGE_LIMIT = 'message_limit'.freeze
|
||||
|
||||
include Labelable
|
||||
include LlmFormattable
|
||||
include AssignmentHandler
|
||||
@@ -133,6 +137,58 @@ class Conversation < ApplicationRecord
|
||||
Conversations::MessageWindowService.new(self).can_reply?
|
||||
end
|
||||
|
||||
def message_limit
|
||||
Limits.conversation_message_limit
|
||||
end
|
||||
|
||||
def message_limit_reached?
|
||||
messages.where(account_id: account_id).reorder(nil).limit(message_limit).count >= message_limit
|
||||
end
|
||||
|
||||
def manual_message_creation_locked?
|
||||
message_creation_lock_data['locked'] == true
|
||||
end
|
||||
|
||||
def message_creation_locked?
|
||||
manual_message_creation_locked? || message_limit_reached?
|
||||
end
|
||||
|
||||
def message_creation_lock_reason
|
||||
message_creation_lock_reason_for(message_limit_reached?, manual_message_creation_locked?)
|
||||
end
|
||||
|
||||
def message_creation_lock_state
|
||||
limit_reached = message_limit_reached?
|
||||
manually_locked = manual_message_creation_locked?
|
||||
|
||||
{
|
||||
message_limit: message_limit,
|
||||
message_limit_reached: limit_reached,
|
||||
message_creation_locked: manually_locked || limit_reached,
|
||||
message_creation_lock_reason: message_creation_lock_reason_for(limit_reached, manually_locked)
|
||||
}
|
||||
end
|
||||
|
||||
def message_creation_lock_data
|
||||
additional_attributes&.fetch(MESSAGE_CREATION_LOCK_KEY, {}) || {}
|
||||
end
|
||||
|
||||
def lock_message_creation!(reason: nil)
|
||||
lock_data = {
|
||||
'locked' => true,
|
||||
'reason' => reason.presence || MESSAGE_CREATION_LOCK_REASON_MANUAL,
|
||||
'locked_at' => Time.current.iso8601
|
||||
}
|
||||
|
||||
updated_additional_attributes = (additional_attributes || {}).merge(MESSAGE_CREATION_LOCK_KEY => lock_data)
|
||||
update!(additional_attributes: updated_additional_attributes)
|
||||
end
|
||||
|
||||
def unlock_message_creation!
|
||||
updated_additional_attributes = (additional_attributes || {}).except(MESSAGE_CREATION_LOCK_KEY)
|
||||
update!(additional_attributes: updated_additional_attributes)
|
||||
end
|
||||
|
||||
def language
|
||||
additional_attributes&.dig('conversation_language')
|
||||
end
|
||||
@@ -243,6 +299,11 @@ class Conversation < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
def message_creation_lock_reason_for(limit_reached, manually_locked)
|
||||
return MESSAGE_CREATION_LOCK_REASON_MESSAGE_LIMIT if limit_reached
|
||||
return MESSAGE_CREATION_LOCK_REASON_MANUAL if manually_locked
|
||||
end
|
||||
|
||||
def execute_after_update_commit_callbacks
|
||||
handle_resolved_status_change
|
||||
notify_status_change
|
||||
@@ -315,7 +376,10 @@ class Conversation < ApplicationRecord
|
||||
def allowed_keys?
|
||||
(
|
||||
previous_changes.keys.intersect?(list_of_keys) ||
|
||||
(previous_changes['additional_attributes'].present? && previous_changes['additional_attributes'][1].keys.intersect?(%w[conversation_language]))
|
||||
(
|
||||
previous_changes['additional_attributes'].present? &&
|
||||
previous_changes['additional_attributes'][1].keys.intersect?(%w[conversation_language message_creation_lock])
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ class Message < ApplicationRecord
|
||||
}.to_json.freeze
|
||||
|
||||
before_validation :ensure_content_type
|
||||
before_validation :ensure_message_creation_unlocked, on: :create
|
||||
before_validation :prevent_message_flooding
|
||||
before_save :ensure_processed_message_content
|
||||
before_save :ensure_in_reply_to
|
||||
@@ -160,6 +161,7 @@ class Message < ApplicationRecord
|
||||
assignee_id: conversation.assignee_id,
|
||||
unread_count: conversation.unread_incoming_messages.count,
|
||||
last_activity_at: conversation.last_activity_at.to_i,
|
||||
**conversation.message_creation_lock_state,
|
||||
contact_inbox: { source_id: conversation.contact_inbox.source_id }
|
||||
}
|
||||
end
|
||||
@@ -285,6 +287,13 @@ class Message < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
def ensure_message_creation_unlocked
|
||||
return if conversation.blank?
|
||||
|
||||
locked_conversation = Conversation.lock.find(conversation.id)
|
||||
raise CustomExceptions::ConversationMessageCreationLocked, locked_conversation if locked_conversation.message_creation_locked?
|
||||
end
|
||||
|
||||
def prevent_message_flooding
|
||||
# Added this to cover the validation specs in messages
|
||||
# We can revisit and see if we can remove this later
|
||||
|
||||
@@ -17,7 +17,7 @@ class Conversations::EventDataPresenter < SimpleDelegator
|
||||
first_reply_created_at: first_reply_created_at,
|
||||
priority: priority,
|
||||
waiting_since: waiting_since.to_i,
|
||||
**push_timestamps
|
||||
**push_timestamps.merge(message_creation_lock_data)
|
||||
}
|
||||
end
|
||||
|
||||
@@ -49,6 +49,10 @@ class Conversations::EventDataPresenter < SimpleDelegator
|
||||
}
|
||||
end
|
||||
|
||||
def message_creation_lock_data
|
||||
message_creation_lock_state
|
||||
end
|
||||
|
||||
def push_timestamps
|
||||
{
|
||||
agent_last_seen_at: agent_last_seen_at.to_i,
|
||||
|
||||
@@ -12,6 +12,8 @@ class AutomationRules::ActionService < ActionService
|
||||
action = action.with_indifferent_access
|
||||
begin
|
||||
send(action[:action_name], action[:action_params])
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info("Skipping automation action #{action[:action_name]} because message creation is locked (#{e.message})")
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: @account).capture_exception
|
||||
end
|
||||
|
||||
@@ -3,7 +3,16 @@ class CsatSurveyService
|
||||
|
||||
def perform
|
||||
return unless should_send_csat_survey?
|
||||
return drop_locked_csat_survey if conversation.message_creation_locked?
|
||||
|
||||
send_csat_survey
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
delegate :inbox, :contact, to: :conversation
|
||||
|
||||
def send_csat_survey
|
||||
if whatsapp_channel? && template_available_and_approved?
|
||||
send_whatsapp_template_survey
|
||||
elsif inbox.twilio_whatsapp? && twilio_template_available_and_approved?
|
||||
@@ -15,10 +24,6 @@ class CsatSurveyService
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
delegate :inbox, :contact, to: :conversation
|
||||
|
||||
def should_send_csat_survey?
|
||||
conversation_allows_csat? && csat_enabled? && !csat_already_sent? && csat_allowed_by_survey_rules?
|
||||
end
|
||||
@@ -115,6 +120,8 @@ class CsatSurveyService
|
||||
message_id = inbox.channel.provider_service.send_template(phone_number, template_info, message)
|
||||
|
||||
message.update!(source_id: message_id) if message_id.present?
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
log_message_creation_locked(e)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error sending WhatsApp CSAT template for conversation #{conversation.id}: #{e.message}"
|
||||
end
|
||||
@@ -164,6 +171,8 @@ class CsatSurveyService
|
||||
)
|
||||
|
||||
message.update!(source_id: result[:message_id]) if result[:success] && result[:message_id].present?
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
log_message_creation_locked(e)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error sending Twilio WhatsApp CSAT template for conversation #{conversation.id}: #{e.message}"
|
||||
end
|
||||
@@ -178,4 +187,12 @@ class CsatSurveyService
|
||||
}
|
||||
::Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params) if content
|
||||
end
|
||||
|
||||
def log_message_creation_locked(error)
|
||||
Rails.logger.info("Skipping CSAT survey for conversation #{conversation.id} because message creation is locked (#{error.message})")
|
||||
end
|
||||
|
||||
def drop_locked_csat_survey
|
||||
log_message_creation_locked(CustomExceptions::ConversationMessageCreationLocked.new(conversation))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,6 +12,8 @@ class Macros::ExecutionService < ActionService
|
||||
action = action.with_indifferent_access
|
||||
begin
|
||||
send(action[:action_name], action[:action_params])
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info("Skipping macro action #{action[:action_name]} because message creation is locked (#{e.message})")
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: @account).capture_exception
|
||||
end
|
||||
|
||||
@@ -9,6 +9,8 @@ class MessageTemplates::Template::AutoResolve
|
||||
else
|
||||
create_auto_resolve_not_sent_activity_message
|
||||
end
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info("Skipping auto-resolve template because message creation is locked (#{e.message})")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -5,6 +5,8 @@ class MessageTemplates::Template::CsatSurvey
|
||||
ActiveRecord::Base.transaction do
|
||||
conversation.messages.create!(csat_survey_message_params)
|
||||
end
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info("Skipping CSAT survey template because message creation is locked (#{e.message})")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -6,6 +6,9 @@ class MessageTemplates::Template::EmailCollect
|
||||
conversation.messages.create!(ways_to_reach_you_message_params)
|
||||
conversation.messages.create!(email_input_box_template_message_params)
|
||||
end
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info("Skipping email collect template because message creation is locked (#{e.message})")
|
||||
true
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
|
||||
true
|
||||
|
||||
@@ -5,6 +5,9 @@ class MessageTemplates::Template::Greeting
|
||||
ActiveRecord::Base.transaction do
|
||||
conversation.messages.create!(greeting_message_params)
|
||||
end
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info("Skipping greeting template because message creation is locked (#{e.message})")
|
||||
true
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
|
||||
true
|
||||
|
||||
@@ -13,6 +13,9 @@ class MessageTemplates::Template::OutOfOffice
|
||||
ActiveRecord::Base.transaction do
|
||||
conversation.messages.create!(out_of_office_message_params)
|
||||
end
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info("Skipping out-of-office template because message creation is locked (#{e.message})")
|
||||
true
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
|
||||
true
|
||||
|
||||
@@ -42,6 +42,11 @@ json.additional_attributes conversation.additional_attributes
|
||||
json.agent_last_seen_at conversation.agent_last_seen_at.to_i
|
||||
json.assignee_last_seen_at conversation.assignee_last_seen_at.to_i
|
||||
json.can_reply conversation.can_reply?
|
||||
message_creation_lock_state = conversation.message_creation_lock_state
|
||||
json.message_limit message_creation_lock_state[:message_limit]
|
||||
json.message_limit_reached message_creation_lock_state[:message_limit_reached]
|
||||
json.message_creation_locked message_creation_lock_state[:message_creation_locked]
|
||||
json.message_creation_lock_reason message_creation_lock_state[:message_creation_lock_reason]
|
||||
json.contact_last_seen_at conversation.contact_last_seen_at.to_i
|
||||
json.custom_attributes conversation.custom_attributes
|
||||
json.inbox_id conversation.inbox_id
|
||||
|
||||
@@ -3,6 +3,7 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
|
||||
|
||||
before_action :set_call, only: %i[show accept reject terminate upload_recording]
|
||||
before_action :set_conversation, only: :initiate
|
||||
before_action :ensure_message_creation_unlocked, only: :initiate
|
||||
before_action :ensure_calling_enabled, only: :initiate
|
||||
before_action :ensure_sdp_offer, only: :initiate
|
||||
before_action :ensure_contact_phone, only: :initiate
|
||||
@@ -84,6 +85,12 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
|
||||
render_could_not_create_error(I18n.t('errors.whatsapp.calls.contact_phone_required'))
|
||||
end
|
||||
|
||||
def ensure_message_creation_unlocked
|
||||
return unless @conversation.message_creation_locked?
|
||||
|
||||
raise CustomExceptions::ConversationMessageCreationLocked, @conversation
|
||||
end
|
||||
|
||||
def ensure_recording_present
|
||||
return if params[:recording].present?
|
||||
|
||||
|
||||
@@ -73,8 +73,13 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
|
||||
process_v1_handoff
|
||||
elsif conversation_pending?
|
||||
ActiveRecord::Base.transaction do
|
||||
create_messages
|
||||
create_messages_and_increment_usage
|
||||
end
|
||||
end
|
||||
|
||||
def create_messages_and_increment_usage
|
||||
ActiveRecord::Base.transaction do
|
||||
if create_messages
|
||||
Rails.logger.info("[CAPTAIN][ResponseBuilderJob] Incrementing response usage for #{account.id}")
|
||||
account.increment_response_usage
|
||||
end
|
||||
@@ -181,6 +186,10 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
additional_attributes: additional_attrs,
|
||||
preserve_waiting_since: preserve_waiting_since
|
||||
)
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info(
|
||||
"[CAPTAIN][ResponseBuilderJob] Dropped message for conversation #{@conversation.display_id}: #{e.message}"
|
||||
)
|
||||
end
|
||||
|
||||
def handle_error(error)
|
||||
|
||||
@@ -100,26 +100,30 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
|
||||
end
|
||||
|
||||
def create_private_note(conversation, inbox, content)
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
private: true,
|
||||
sender: inbox.captain_assistant,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: content
|
||||
)
|
||||
create_message_or_drop(conversation, 'private note') do
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
private: true,
|
||||
sender: inbox.captain_assistant,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: content
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def create_resolution_message(conversation, inbox)
|
||||
I18n.with_locale(inbox.account.locale) do
|
||||
resolution_message = inbox.captain_assistant.config['resolution_message']
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: resolution_message.presence || I18n.t('conversations.activity.auto_resolution_message'),
|
||||
sender: inbox.captain_assistant
|
||||
)
|
||||
create_message_or_drop(conversation, 'resolution message') do
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: resolution_message.presence || I18n.t('conversations.activity.auto_resolution_message'),
|
||||
sender: inbox.captain_assistant
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -127,13 +131,23 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
|
||||
handoff_message = inbox.captain_assistant.config['handoff_message']
|
||||
return if handoff_message.blank?
|
||||
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
sender: inbox.captain_assistant,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: handoff_message,
|
||||
preserve_waiting_since: true
|
||||
create_message_or_drop(conversation, 'handoff message') do
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
sender: inbox.captain_assistant,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: handoff_message,
|
||||
preserve_waiting_since: true
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def create_message_or_drop(conversation, message_type)
|
||||
yield
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info(
|
||||
"[CAPTAIN][InboxPendingConversationsResolutionJob] Dropped #{message_type} for conversation #{conversation.display_id}: #{e.message}"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -57,14 +57,20 @@ module Enterprise::MessageTemplates::HookExecutionService
|
||||
return unless conversation.pending?
|
||||
|
||||
Rails.logger.info("Captain limit exceeded, performing handoff mid-conversation for conversation: #{conversation.id}")
|
||||
create_handoff_message
|
||||
conversation.bot_handoff!
|
||||
send_out_of_office_message_after_handoff
|
||||
end
|
||||
|
||||
def create_handoff_message
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account.id,
|
||||
inbox_id: conversation.inbox.id,
|
||||
content: 'Transferring to another agent for further assistance.'
|
||||
)
|
||||
conversation.bot_handoff!
|
||||
send_out_of_office_message_after_handoff
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info("[CaptainHandoff] Dropped handoff message for conversation #{conversation.display_id}: #{e.message}")
|
||||
end
|
||||
|
||||
def send_out_of_office_message_after_handoff
|
||||
|
||||
@@ -9,7 +9,7 @@ class Captain::Tools::AddPrivateNoteTool < Captain::Tools::BasePublicTool
|
||||
return 'Note content is required' if note.blank?
|
||||
|
||||
log_tool_usage('add_private_note', { conversation_id: conversation.id, note_length: note.length })
|
||||
create_private_note(conversation, note)
|
||||
return 'Message creation is locked for this conversation' unless create_private_note(conversation, note)
|
||||
|
||||
'Private note added successfully'
|
||||
end
|
||||
@@ -25,6 +25,9 @@ class Captain::Tools::AddPrivateNoteTool < Captain::Tools::BasePublicTool
|
||||
content: note,
|
||||
private: true
|
||||
)
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info("[CAPTAIN][AddPrivateNoteTool] Dropped private note for conversation #{conversation.display_id}: #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
def permissions
|
||||
|
||||
@@ -25,14 +25,7 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
|
||||
|
||||
def trigger_handoff(conversation, reason)
|
||||
# post the reason as a private note
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
private: true,
|
||||
sender: @assistant,
|
||||
account: conversation.account,
|
||||
inbox: conversation.inbox,
|
||||
content: reason
|
||||
)
|
||||
create_private_note(conversation, reason)
|
||||
|
||||
# Trigger the bot handoff (sets status to open + dispatches events)
|
||||
conversation.bot_handoff!
|
||||
@@ -49,6 +42,19 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
|
||||
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
|
||||
end
|
||||
|
||||
def create_private_note(conversation, reason)
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
private: true,
|
||||
sender: @assistant,
|
||||
account: conversation.account,
|
||||
inbox: conversation.inbox,
|
||||
content: reason
|
||||
)
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info("[CAPTAIN][HandoffTool] Dropped handoff note for conversation #{conversation.display_id}: #{e.message}")
|
||||
end
|
||||
|
||||
# TODO: Future enhancement - Add team assignment capability
|
||||
# This tool could be enhanced to:
|
||||
# 1. Accept team_id parameter for routing to specific teams
|
||||
|
||||
@@ -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)
|
||||
|
||||
process_content(message)
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info("Skipping bot processor response because message creation is locked (#{e.message})")
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: (hook&.account || agent_bot&.account)).capture_exception
|
||||
end
|
||||
|
||||
@@ -2,6 +2,8 @@ class Integrations::Dyte::ProcessorService
|
||||
pattr_initialize [:account!, :conversation!]
|
||||
|
||||
def create_a_meeting(agent)
|
||||
raise CustomExceptions::ConversationMessageCreationLocked, conversation if conversation.message_creation_locked?
|
||||
|
||||
return missing_realtimekit_credentials_response if realtimekit_credentials_missing?
|
||||
|
||||
title = I18n.t('integration_apps.dyte.meeting_name', agent_name: agent.available_name)
|
||||
|
||||
@@ -7,6 +7,9 @@ module Integrations::Slack::SlackMessageHelper
|
||||
rescue Slack::Web::Api::Errors::MissingScope => e
|
||||
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
|
||||
disable_and_reauthorize
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info("[SlackMessageHelper] Dropped message for conversation #{conversation.display_id}: #{e.message}")
|
||||
success_response
|
||||
end
|
||||
|
||||
def handle_conversation
|
||||
|
||||
@@ -14,4 +14,11 @@ module Limits
|
||||
def self.conversation_message_per_minute_limit
|
||||
ENV.fetch('CONVERSATION_MESSAGE_PER_MINUTE_LIMIT', '200').to_i
|
||||
end
|
||||
|
||||
def self.conversation_message_limit
|
||||
limit = Integer(ENV.fetch('CONVERSATION_MESSAGE_LIMIT', '10000'), 10)
|
||||
raise ArgumentError, 'CONVERSATION_MESSAGE_LIMIT must be greater than 0' unless limit.positive?
|
||||
|
||||
limit
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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(Conversation.count).to eq(inital_count + 1)
|
||||
end
|
||||
|
||||
it 'drops the message without reporting an exception when the conversation message limit is reached' do
|
||||
existing_conversation = create(:conversation, account_id: facebook_channel.inbox.account.id, inbox_id: facebook_channel.inbox.id,
|
||||
contact_id: contact.id, contact_inbox_id: contact_inbox.id,
|
||||
status: :open)
|
||||
create(:message, conversation: existing_conversation, account: facebook_channel.inbox.account, inbox: facebook_channel.inbox)
|
||||
|
||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
||||
allow(fb_object).to receive(:get_object).and_return(
|
||||
{ first_name: 'Jane', last_name: 'Dae', profile_pic: 'https://chatwoot-assets.local/sample.png' }.with_indifferent_access
|
||||
)
|
||||
expect(ChatwootExceptionTracker).not_to receive(:new)
|
||||
|
||||
with_modified_env CONVERSATION_MESSAGE_LIMIT: '1' do
|
||||
expect { mocked_message_builder }.not_to(change { existing_conversation.messages.count })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when lock to single conversation is enabled' do
|
||||
|
||||
@@ -101,6 +101,28 @@ describe Messages::Instagram::Messenger::MessageBuilder do
|
||||
)
|
||||
end
|
||||
|
||||
it 'drops the message without reporting an exception when the conversation message limit is reached' do
|
||||
messaging = dm_params[:entry][0]['messaging'][0]
|
||||
sender_id = messaging['sender']['id']
|
||||
contact = create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
|
||||
contact_inbox = contact.contact_inboxes.find_by!(inbox: instagram_messenger_inbox)
|
||||
conversation = create(
|
||||
:conversation,
|
||||
account_id: account.id,
|
||||
inbox_id: instagram_messenger_inbox.id,
|
||||
contact_id: contact.id,
|
||||
contact_inbox_id: contact_inbox.id,
|
||||
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' }
|
||||
)
|
||||
create(:message, conversation: conversation, account: account, inbox: instagram_messenger_inbox)
|
||||
|
||||
expect(ChatwootExceptionTracker).not_to receive(:new)
|
||||
|
||||
with_modified_env CONVERSATION_MESSAGE_LIMIT: '1' do
|
||||
expect { described_class.new(messaging, instagram_messenger_inbox).perform }.not_to(change { conversation.messages.count })
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates message with for reply with story id' do
|
||||
messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
|
||||
sender_id = messaging['sender']['id']
|
||||
|
||||
@@ -82,6 +82,28 @@ RSpec.describe 'Conversation Messages API', type: :request do
|
||||
expect(conversation.messages.last.attachments.first.file_type).to eq('image')
|
||||
end
|
||||
|
||||
it 'returns structured lock metadata when message creation is locked' do
|
||||
with_modified_env 'CONVERSATION_MESSAGE_LIMIT': '1' do
|
||||
create(:message, conversation: conversation, account: account, inbox: inbox)
|
||||
|
||||
post api_v1_account_conversation_messages_url(account_id: account.id, conversation_id: conversation.display_id),
|
||||
params: { content: 'test-message', private: true },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
json_response = response.parsed_body
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json_response).to include(
|
||||
'error_code' => 'conversation_message_creation_locked',
|
||||
'message_limit' => 1,
|
||||
'message_limit_reached' => true,
|
||||
'message_creation_locked' => true,
|
||||
'message_creation_lock_reason' => 'message_limit'
|
||||
)
|
||||
expect(conversation.reload.messages.count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when api inbox' do
|
||||
let(:api_channel) { create(:channel_api, account: account) }
|
||||
let(:api_inbox) { create(:inbox, channel: api_channel, account: account) }
|
||||
@@ -277,6 +299,23 @@ RSpec.describe 'Conversation Messages API', type: :request do
|
||||
expect(message.reload.status).to eq('sent')
|
||||
expect(message.reload.content_attributes['external_error']).to be_nil
|
||||
end
|
||||
|
||||
it 'returns structured lock metadata when retry is locked' do
|
||||
with_modified_env 'CONVERSATION_MESSAGE_LIMIT': '1' do
|
||||
post "/api/v1/accounts/#{account.id}/conversations/#{message.conversation.display_id}/messages/#{message.id}/retry",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
json_response = response.parsed_body
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json_response).to include(
|
||||
'error_code' => 'conversation_message_creation_locked',
|
||||
'message_creation_locked' => true,
|
||||
'message_creation_lock_reason' => 'message_limit'
|
||||
)
|
||||
expect(message.reload.status).to eq('failed')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the message id is invalid' do
|
||||
|
||||
@@ -60,6 +60,27 @@ RSpec.describe 'Dyte Integration API', type: :request do
|
||||
expect(conversation.display_id).to eq(response_body['conversation_id'])
|
||||
expect(last_message.id).to eq(response_body['id'])
|
||||
end
|
||||
|
||||
it 'returns lock metadata without creating an external meeting when message creation is locked' do
|
||||
create(:message, conversation: conversation, account: account, inbox: conversation.inbox)
|
||||
expect(Dyte).not_to receive(:new)
|
||||
|
||||
with_modified_env CONVERSATION_MESSAGE_LIMIT: '1' do
|
||||
post create_a_meeting_api_v1_account_integrations_dyte_url(account),
|
||||
params: { conversation_id: conversation.display_id },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
end
|
||||
|
||||
response_body = response.parsed_body
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response_body).to include(
|
||||
'error_code' => 'conversation_message_creation_locked',
|
||||
'message_creation_locked' => true,
|
||||
'message_creation_lock_reason' => 'message_limit'
|
||||
)
|
||||
expect(conversation.reload.messages.count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent with inbox access and the Dyte API is errored' do
|
||||
|
||||
@@ -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)')
|
||||
end
|
||||
|
||||
it 'returns structured lock metadata when message creation is locked', :skip_before do
|
||||
create(:message, account: account, inbox: web_widget.inbox, conversation: conversation)
|
||||
message_params = { content: 'hello world', timestamp: Time.current }
|
||||
message_count_before_request = conversation.reload.messages.count
|
||||
|
||||
with_modified_env CONVERSATION_MESSAGE_LIMIT: '1' do
|
||||
post api_v1_widget_messages_url,
|
||||
params: { website_token: web_widget.website_token, message: message_params },
|
||||
headers: { 'X-Auth-Token' => token },
|
||||
as: :json
|
||||
end
|
||||
|
||||
json_response = response.parsed_body
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json_response).to include(
|
||||
'error_code' => 'conversation_message_creation_locked',
|
||||
'message_limit' => 1,
|
||||
'message_limit_reached' => true,
|
||||
'message_creation_locked' => true,
|
||||
'message_creation_lock_reason' => 'message_limit'
|
||||
)
|
||||
expect(conversation.reload.messages.count).to eq(message_count_before_request)
|
||||
end
|
||||
|
||||
it 'creates message in conversation with a valid reply to' do
|
||||
message_params = { content: 'hello world reply', timestamp: Time.current, reply_to: conversation.messages.first.id }
|
||||
post api_v1_widget_messages_url,
|
||||
|
||||
@@ -53,6 +53,27 @@ RSpec.describe 'Public Inbox Contact Conversation Messages API', type: :request
|
||||
expect(json_response['message']).to eq('Content is too long (maximum is 150000 characters)')
|
||||
end
|
||||
|
||||
it 'returns structured lock metadata when message creation is locked' do
|
||||
create(:message, account: conversation.account, inbox: conversation.inbox, conversation: conversation)
|
||||
message_count_before_request = conversation.reload.messages.count
|
||||
|
||||
with_modified_env CONVERSATION_MESSAGE_LIMIT: '1' do
|
||||
post "/public/api/v1/inboxes/#{api_channel.identifier}/contacts/#{contact_inbox.source_id}/conversations/#{conversation.display_id}/messages",
|
||||
params: { content: 'hello' }
|
||||
end
|
||||
|
||||
json_response = response.parsed_body
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json_response).to include(
|
||||
'error_code' => 'conversation_message_creation_locked',
|
||||
'message_limit' => 1,
|
||||
'message_limit_reached' => true,
|
||||
'message_creation_locked' => true,
|
||||
'message_creation_lock_reason' => 'message_limit'
|
||||
)
|
||||
expect(conversation.reload.messages.count).to eq(message_count_before_request)
|
||||
end
|
||||
|
||||
it 'creates attachment message in conversation' do
|
||||
file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
|
||||
post "/public/api/v1/inboxes/#{api_channel.identifier}/contacts/#{contact_inbox.source_id}/conversations/#{conversation.display_id}/messages",
|
||||
|
||||
@@ -104,6 +104,24 @@ RSpec.describe 'WhatsApp Calls API', type: :request do
|
||||
expect(Call.find_by(provider_call_id: 'wacid_outbound')).to have_attributes(direction: 'outgoing', status: 'ringing')
|
||||
end
|
||||
|
||||
it 'returns the lock response without calling Meta when the conversation is message-creation locked' do
|
||||
initiate_conversation.lock_message_creation!(reason: 'manual')
|
||||
expect(provider_service).not_to receive(:initiate_call)
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
|
||||
params: { conversation_id: initiate_conversation.display_id, sdp_offer: 'sdp_offer' },
|
||||
headers: agent.create_new_auth_token
|
||||
end.not_to change(Call, :count)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body).to include(
|
||||
'error_code' => 'conversation_message_creation_locked',
|
||||
'message_creation_locked' => true,
|
||||
'message_creation_lock_reason' => 'manual'
|
||||
)
|
||||
end
|
||||
|
||||
it 'sends a permission request and records the wamid when Meta returns NoCallPermission' do
|
||||
allow(provider_service).to receive(:initiate_call).and_raise(Voice::CallErrors::NoCallPermission)
|
||||
allow(provider_service).to receive(:send_call_permission_request).and_return({ 'messages' => [{ 'id' => 'wamid.req_xyz' }] })
|
||||
|
||||
@@ -19,6 +19,11 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
|
||||
end
|
||||
|
||||
context 'when captain_tasks is disabled' do
|
||||
before do
|
||||
allow(inbox.account).to receive(:feature_enabled?).and_call_original
|
||||
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(false)
|
||||
end
|
||||
|
||||
it 'resolves pending conversations inactive for over 1 hour' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
|
||||
@@ -59,6 +59,18 @@ RSpec.describe Captain::Tools::AddPrivateNoteTool, type: :model do
|
||||
|
||||
tool.perform(tool_context, note: 'This is a test note')
|
||||
end
|
||||
|
||||
it 'returns a locked response when the private note is dropped by the message lock' do
|
||||
with_modified_env 'CONVERSATION_MESSAGE_LIMIT': '1' do
|
||||
create(:message, conversation: conversation, account: account, inbox: inbox)
|
||||
expect(ChatwootExceptionTracker).not_to receive(:new)
|
||||
|
||||
expect do
|
||||
result = tool.perform(tool_context, note: 'This is a private note')
|
||||
expect(result).to eq('Message creation is locked for this conversation')
|
||||
end.not_to change(Message, :count)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with blank note content' do
|
||||
|
||||
@@ -86,6 +86,22 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do
|
||||
|
||||
tool.perform(tool_context, reason: reason)
|
||||
end
|
||||
|
||||
it 'hands off even when the private note is dropped by the message lock' do
|
||||
conversation.update!(status: :pending)
|
||||
|
||||
with_modified_env 'CONVERSATION_MESSAGE_LIMIT': '1' do
|
||||
create(:message, conversation: conversation, account: account, inbox: inbox)
|
||||
expect(ChatwootExceptionTracker).not_to receive(:new)
|
||||
|
||||
expect do
|
||||
result = tool.perform(tool_context, reason: 'Customer needs specialized support')
|
||||
expect(result).to eq('Conversation handed off to human support team (Reason: Customer needs specialized support)')
|
||||
end.not_to change(Message, :count)
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'without reason provided' do
|
||||
|
||||
@@ -99,6 +99,20 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
|
||||
it 'performs handoff when the handoff message is dropped by the conversation message limit' do
|
||||
create(:message, conversation: conversation, message_type: :outgoing, account: account)
|
||||
|
||||
expect(ChatwootExceptionTracker).not_to receive(:new)
|
||||
|
||||
with_modified_env CONVERSATION_MESSAGE_LIMIT: '2' do
|
||||
expect do
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
end.to change { conversation.messages.count }.by(1)
|
||||
end
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -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)
|
||||
end
|
||||
|
||||
it 'drops message creation when the conversation is locked' do
|
||||
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox)
|
||||
|
||||
with_modified_env CONVERSATION_MESSAGE_LIMIT: '1' do
|
||||
builder = described_class.new(message_params)
|
||||
allow(builder).to receive(:resolve_slack_sender).and_return([nil, nil, nil])
|
||||
|
||||
expect(builder.perform).to eq({ status: 'success' })
|
||||
expect(conversation.reload.messages.count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not create message for invalid event type' do
|
||||
messages_count = conversation.messages.count
|
||||
message_params[:type] = 'invalid_event_type'
|
||||
|
||||
@@ -104,6 +104,53 @@ RSpec.describe Conversation do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'message creation lock' do
|
||||
let(:conversation) { create(:conversation) }
|
||||
let(:message_params) { { conversation: conversation, account: conversation.account, inbox: conversation.inbox } }
|
||||
|
||||
it 'detects when the message limit has been reached' do
|
||||
with_modified_env 'CONVERSATION_MESSAGE_LIMIT': '2' do
|
||||
create(:message, **message_params)
|
||||
|
||||
expect(conversation.reload.message_limit).to eq(2)
|
||||
expect(conversation.message_limit_reached?).to be false
|
||||
|
||||
create(:message, **message_params)
|
||||
|
||||
expect(conversation.reload.message_limit_reached?).to be true
|
||||
expect(conversation.message_creation_locked?).to be true
|
||||
expect(conversation.message_creation_lock_reason).to eq('message_limit')
|
||||
end
|
||||
end
|
||||
|
||||
it 'stores and clears manual lock metadata' do
|
||||
freeze_time do
|
||||
conversation.lock_message_creation!(reason: 'maintenance')
|
||||
|
||||
lock_data = conversation.reload.additional_attributes['message_creation_lock']
|
||||
expect(lock_data).to include(
|
||||
'locked' => true,
|
||||
'reason' => 'maintenance',
|
||||
'locked_at' => Time.current.iso8601
|
||||
)
|
||||
expect(conversation.manual_message_creation_locked?).to be true
|
||||
expect(conversation.message_creation_locked?).to be true
|
||||
expect(conversation.message_creation_lock_reason).to eq('manual')
|
||||
|
||||
conversation.unlock_message_creation!
|
||||
|
||||
expect(conversation.reload.additional_attributes).not_to have_key('message_creation_lock')
|
||||
expect(conversation.message_creation_locked?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
it 'fails loudly when the message limit config is invalid' do
|
||||
with_modified_env 'CONVERSATION_MESSAGE_LIMIT': 'invalid' do
|
||||
expect { conversation.message_limit }.to raise_error(ArgumentError)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.after_update' do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:old_assignee) do
|
||||
@@ -620,6 +667,10 @@ RSpec.describe Conversation do
|
||||
contact_inbox: conversation.contact_inbox,
|
||||
timestamp: conversation.last_activity_at.to_i,
|
||||
can_reply: true,
|
||||
message_limit: conversation.message_limit,
|
||||
message_limit_reached: conversation.message_limit_reached?,
|
||||
message_creation_locked: conversation.message_creation_locked?,
|
||||
message_creation_lock_reason: conversation.message_creation_lock_reason,
|
||||
channel: 'Channel::WebWidget',
|
||||
snoozed_until: conversation.snoozed_until,
|
||||
custom_attributes: conversation.custom_attributes,
|
||||
|
||||
@@ -141,6 +141,10 @@ RSpec.describe Message do
|
||||
source_id: message.conversation.contact_inbox.source_id
|
||||
},
|
||||
last_activity_at: message.conversation.last_activity_at.to_i,
|
||||
message_limit: message.conversation.message_limit,
|
||||
message_limit_reached: message.conversation.message_limit_reached?,
|
||||
message_creation_locked: message.conversation.message_creation_locked?,
|
||||
message_creation_lock_reason: message.conversation.message_creation_lock_reason,
|
||||
unread_count: message.conversation.unread_incoming_messages.count
|
||||
},
|
||||
sentiment: {},
|
||||
@@ -154,6 +158,47 @@ RSpec.describe Message do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'message creation lock' do
|
||||
let(:conversation) { create(:conversation) }
|
||||
let(:message_params) { { conversation: conversation, account: conversation.account, inbox: conversation.inbox } }
|
||||
let(:locked_error_name) { 'CustomExceptions::ConversationMessageCreationLocked' }
|
||||
|
||||
it 'allows the capped message and blocks the next message' do
|
||||
with_modified_env 'CONVERSATION_MESSAGE_LIMIT': '2' do
|
||||
create(:message, **message_params)
|
||||
|
||||
expect { create(:message, **message_params) }.to change(described_class, :count).by(1)
|
||||
expect { create(:message, **message_params) }
|
||||
.to raise_error(StandardError) { |error| expect(error.class.name).to eq(locked_error_name) }
|
||||
end
|
||||
end
|
||||
|
||||
it 'counts incoming, outgoing, template, private, and activity messages' do
|
||||
with_modified_env 'CONVERSATION_MESSAGE_LIMIT': '5' do
|
||||
create(:message, message_type: :incoming, **message_params)
|
||||
create(:message, message_type: :outgoing, **message_params)
|
||||
create(:message, message_type: :template, **message_params)
|
||||
create(:message, message_type: :outgoing, private: true, **message_params)
|
||||
create(:message, message_type: :activity, **message_params)
|
||||
|
||||
expect(conversation.reload.message_limit_reached?).to be true
|
||||
expect { create(:message, **message_params) }
|
||||
.to raise_error(StandardError) { |error| expect(error.class.name).to eq(locked_error_name) }
|
||||
end
|
||||
end
|
||||
|
||||
it 'blocks message creation when manually locked and allows it after unlock' do
|
||||
conversation.lock_message_creation!(reason: 'ops')
|
||||
|
||||
expect { create(:message, **message_params) }
|
||||
.to raise_error(StandardError) { |error| expect(error.class.name).to eq(locked_error_name) }
|
||||
|
||||
conversation.unlock_message_creation!
|
||||
|
||||
expect { create(:message, **message_params) }.to change(described_class, :count).by(1)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'message create event' do
|
||||
let!(:conversation) { create(:conversation) }
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@ RSpec.describe Conversations::EventDataPresenter do
|
||||
status: conversation.status,
|
||||
contact_inbox: conversation.contact_inbox,
|
||||
can_reply: conversation.can_reply?,
|
||||
message_limit: conversation.message_limit,
|
||||
message_limit_reached: conversation.message_limit_reached?,
|
||||
message_creation_locked: conversation.message_creation_locked?,
|
||||
message_creation_lock_reason: conversation.message_creation_lock_reason,
|
||||
channel: conversation.inbox.channel_type,
|
||||
timestamp: conversation.last_activity_at.to_i,
|
||||
snoozed_until: conversation.snoozed_until,
|
||||
|
||||
@@ -73,6 +73,18 @@ RSpec.describe AutomationRules::ActionService do
|
||||
expect(message_builder).not_to receive(:perform)
|
||||
described_class.new(rule, account, conversation).perform
|
||||
end
|
||||
|
||||
it 'drops locked message actions without reporting an exception' do
|
||||
rule.update!(actions: [{ action_name: 'send_message', action_params: ['Hello'] }])
|
||||
|
||||
with_modified_env 'CONVERSATION_MESSAGE_LIMIT': '1' do
|
||||
create(:message, conversation: conversation, account: account, inbox: conversation.inbox)
|
||||
allow(Messages::MessageBuilder).to receive(:new).and_call_original
|
||||
expect(ChatwootExceptionTracker).not_to receive(:new)
|
||||
|
||||
expect { described_class.new(rule.reload, account, conversation).perform }.not_to change(Message, :count)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform with send_email_to_team action' do
|
||||
|
||||
@@ -28,6 +28,33 @@ describe CsatSurveyService do
|
||||
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: conversation)
|
||||
expect(csat_template).to have_received(:perform)
|
||||
end
|
||||
|
||||
it 'drops CSAT survey creation when the conversation is locked' do
|
||||
create(:message, conversation: conversation, account: account, inbox: inbox)
|
||||
|
||||
with_modified_env CONVERSATION_MESSAGE_LIMIT: '1' do
|
||||
service.perform
|
||||
end
|
||||
|
||||
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
|
||||
expect(Conversations::ActivityMessageJob).not_to have_received(:perform_later)
|
||||
expect(conversation.reload.messages.count).to eq(1)
|
||||
end
|
||||
|
||||
it 'drops Twilio WhatsApp template survey before checking template status when the conversation is locked' do
|
||||
create(:message, conversation: conversation, account: account, inbox: inbox)
|
||||
inbox.update(csat_config: { 'template' => { 'content_sid' => 'HX123' } })
|
||||
allow(conversation).to receive(:inbox).and_return(inbox)
|
||||
allow(inbox).to receive(:twilio_whatsapp?).and_return(true)
|
||||
expect(Twilio::CsatTemplateService).not_to receive(:new)
|
||||
|
||||
with_modified_env CONVERSATION_MESSAGE_LIMIT: '1' do
|
||||
service.perform
|
||||
end
|
||||
|
||||
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
|
||||
expect(conversation.reload.messages.count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when outside messaging window' do
|
||||
@@ -217,6 +244,19 @@ describe CsatSurveyService do
|
||||
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
|
||||
expect(csat_message.content).to eq('Please rate this conversation')
|
||||
end
|
||||
|
||||
it 'drops WhatsApp template survey before checking template status when the conversation is locked' do
|
||||
create(:message, conversation: whatsapp_conversation, account: account, inbox: whatsapp_inbox)
|
||||
expect(mock_provider_service).not_to receive(:get_template_status)
|
||||
expect(mock_provider_service).not_to receive(:send_template)
|
||||
|
||||
with_modified_env CONVERSATION_MESSAGE_LIMIT: '1' do
|
||||
whatsapp_service.perform
|
||||
end
|
||||
|
||||
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
|
||||
expect(whatsapp_conversation.reload.messages.count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when template is not available or not approved' do
|
||||
|
||||
@@ -45,6 +45,19 @@ RSpec.describe Macros::ExecutionService, type: :service do
|
||||
|
||||
service.perform
|
||||
end
|
||||
|
||||
it 'drops locked message actions without reporting an exception' do
|
||||
allow(macro).to receive(:actions).and_return([
|
||||
{ action_name: 'send_message', action_params: ['Locked message'] }
|
||||
])
|
||||
|
||||
with_modified_env 'CONVERSATION_MESSAGE_LIMIT': '1' do
|
||||
create(:message, conversation: conversation, account: account, inbox: conversation.inbox)
|
||||
expect(ChatwootExceptionTracker).not_to receive(:new)
|
||||
|
||||
expect { service.perform }.not_to change(Message, :count)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -29,5 +29,14 @@ describe MessageTemplates::Template::Greeting do
|
||||
expect(conversation.messages.count).to eq(1)
|
||||
expect(conversation.messages.last.content).to eq('Hello welcome to our board.')
|
||||
end
|
||||
|
||||
it 'drops locked greeting messages without reporting an exception' do
|
||||
with_modified_env 'CONVERSATION_MESSAGE_LIMIT': '1' do
|
||||
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox)
|
||||
expect(ChatwootExceptionTracker).not_to receive(:new)
|
||||
|
||||
expect { described_class.new(conversation: conversation).perform }.not_to change(Message, :count)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -80,6 +80,21 @@ properties:
|
||||
last_activity_at:
|
||||
type: integer
|
||||
description: Timestamp of last activity
|
||||
message_limit:
|
||||
type: integer
|
||||
description: Maximum number of messages allowed in the conversation
|
||||
message_limit_reached:
|
||||
type: boolean
|
||||
description: Whether the conversation has reached the message limit
|
||||
message_creation_locked:
|
||||
type: boolean
|
||||
description: Whether new messages are currently blocked for the conversation
|
||||
message_creation_lock_reason:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
enum: ['message_limit', 'manual', null]
|
||||
description: Reason new message creation is blocked
|
||||
contact_inbox:
|
||||
type: object
|
||||
description: Contact inbox details
|
||||
@@ -111,4 +126,4 @@ properties:
|
||||
description: Availability status of the sender
|
||||
thumbnail:
|
||||
type: string
|
||||
description: Thumbnail URL of the sender
|
||||
description: Thumbnail URL of the sender
|
||||
|
||||
@@ -25,6 +25,21 @@ properties:
|
||||
can_reply:
|
||||
type: boolean
|
||||
description: Whether the conversation can be replied to
|
||||
message_limit:
|
||||
type: integer
|
||||
description: Maximum number of messages allowed in the conversation
|
||||
message_limit_reached:
|
||||
type: boolean
|
||||
description: Whether the conversation has reached the message limit
|
||||
message_creation_locked:
|
||||
type: boolean
|
||||
description: Whether new messages are currently blocked for the conversation
|
||||
message_creation_lock_reason:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
enum: ['message_limit', 'manual', null]
|
||||
description: Reason new message creation is blocked
|
||||
contact_last_seen_at:
|
||||
type: number
|
||||
description: The last activity at of the contact
|
||||
@@ -99,4 +114,3 @@ properties:
|
||||
items:
|
||||
type: object
|
||||
description: SLA event objects
|
||||
|
||||
|
||||
@@ -82,6 +82,22 @@ properties:
|
||||
- object
|
||||
- 'null'
|
||||
description: The conversation object
|
||||
properties:
|
||||
message_limit:
|
||||
type: integer
|
||||
description: Maximum number of messages allowed in the conversation
|
||||
message_limit_reached:
|
||||
type: boolean
|
||||
description: Whether the conversation has reached the message limit
|
||||
message_creation_locked:
|
||||
type: boolean
|
||||
description: Whether new messages are currently blocked for the conversation
|
||||
message_creation_lock_reason:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
enum: ['message_limit', 'manual', null]
|
||||
description: Reason new message creation is blocked
|
||||
attachment:
|
||||
type:
|
||||
- object
|
||||
|
||||
+75
-1
@@ -9828,6 +9828,30 @@
|
||||
"type": "boolean",
|
||||
"description": "Whether the conversation can be replied to"
|
||||
},
|
||||
"message_limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of messages allowed in the conversation"
|
||||
},
|
||||
"message_limit_reached": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the conversation has reached the message limit"
|
||||
},
|
||||
"message_creation_locked": {
|
||||
"type": "boolean",
|
||||
"description": "Whether new messages are currently blocked for the conversation"
|
||||
},
|
||||
"message_creation_lock_reason": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"enum": [
|
||||
"message_limit",
|
||||
"manual",
|
||||
null
|
||||
],
|
||||
"description": "Reason new message creation is blocked"
|
||||
},
|
||||
"contact_last_seen_at": {
|
||||
"type": "number",
|
||||
"description": "The last activity at of the contact"
|
||||
@@ -10083,7 +10107,33 @@
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"description": "The conversation object"
|
||||
"description": "The conversation object",
|
||||
"properties": {
|
||||
"message_limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of messages allowed in the conversation"
|
||||
},
|
||||
"message_limit_reached": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the conversation has reached the message limit"
|
||||
},
|
||||
"message_creation_locked": {
|
||||
"type": "boolean",
|
||||
"description": "Whether new messages are currently blocked for the conversation"
|
||||
},
|
||||
"message_creation_lock_reason": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"enum": [
|
||||
"message_limit",
|
||||
"manual",
|
||||
null
|
||||
],
|
||||
"description": "Reason new message creation is blocked"
|
||||
}
|
||||
}
|
||||
},
|
||||
"attachment": {
|
||||
"type": [
|
||||
@@ -15464,6 +15514,30 @@
|
||||
"type": "integer",
|
||||
"description": "Timestamp of last activity"
|
||||
},
|
||||
"message_limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of messages allowed in the conversation"
|
||||
},
|
||||
"message_limit_reached": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the conversation has reached the message limit"
|
||||
},
|
||||
"message_creation_locked": {
|
||||
"type": "boolean",
|
||||
"description": "Whether new messages are currently blocked for the conversation"
|
||||
},
|
||||
"message_creation_lock_reason": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"enum": [
|
||||
"message_limit",
|
||||
"manual",
|
||||
null
|
||||
],
|
||||
"description": "Reason new message creation is blocked"
|
||||
},
|
||||
"contact_inbox": {
|
||||
"type": "object",
|
||||
"description": "Contact inbox details",
|
||||
|
||||
@@ -8335,6 +8335,30 @@
|
||||
"type": "boolean",
|
||||
"description": "Whether the conversation can be replied to"
|
||||
},
|
||||
"message_limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of messages allowed in the conversation"
|
||||
},
|
||||
"message_limit_reached": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the conversation has reached the message limit"
|
||||
},
|
||||
"message_creation_locked": {
|
||||
"type": "boolean",
|
||||
"description": "Whether new messages are currently blocked for the conversation"
|
||||
},
|
||||
"message_creation_lock_reason": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"enum": [
|
||||
"message_limit",
|
||||
"manual",
|
||||
null
|
||||
],
|
||||
"description": "Reason new message creation is blocked"
|
||||
},
|
||||
"contact_last_seen_at": {
|
||||
"type": "number",
|
||||
"description": "The last activity at of the contact"
|
||||
@@ -8590,7 +8614,33 @@
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"description": "The conversation object"
|
||||
"description": "The conversation object",
|
||||
"properties": {
|
||||
"message_limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of messages allowed in the conversation"
|
||||
},
|
||||
"message_limit_reached": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the conversation has reached the message limit"
|
||||
},
|
||||
"message_creation_locked": {
|
||||
"type": "boolean",
|
||||
"description": "Whether new messages are currently blocked for the conversation"
|
||||
},
|
||||
"message_creation_lock_reason": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"enum": [
|
||||
"message_limit",
|
||||
"manual",
|
||||
null
|
||||
],
|
||||
"description": "Reason new message creation is blocked"
|
||||
}
|
||||
}
|
||||
},
|
||||
"attachment": {
|
||||
"type": [
|
||||
@@ -13971,6 +14021,30 @@
|
||||
"type": "integer",
|
||||
"description": "Timestamp of last activity"
|
||||
},
|
||||
"message_limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of messages allowed in the conversation"
|
||||
},
|
||||
"message_limit_reached": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the conversation has reached the message limit"
|
||||
},
|
||||
"message_creation_locked": {
|
||||
"type": "boolean",
|
||||
"description": "Whether new messages are currently blocked for the conversation"
|
||||
},
|
||||
"message_creation_lock_reason": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"enum": [
|
||||
"message_limit",
|
||||
"manual",
|
||||
null
|
||||
],
|
||||
"description": "Reason new message creation is blocked"
|
||||
},
|
||||
"contact_inbox": {
|
||||
"type": "object",
|
||||
"description": "Contact inbox details",
|
||||
|
||||
@@ -1245,6 +1245,30 @@
|
||||
"type": "boolean",
|
||||
"description": "Whether the conversation can be replied to"
|
||||
},
|
||||
"message_limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of messages allowed in the conversation"
|
||||
},
|
||||
"message_limit_reached": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the conversation has reached the message limit"
|
||||
},
|
||||
"message_creation_locked": {
|
||||
"type": "boolean",
|
||||
"description": "Whether new messages are currently blocked for the conversation"
|
||||
},
|
||||
"message_creation_lock_reason": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"enum": [
|
||||
"message_limit",
|
||||
"manual",
|
||||
null
|
||||
],
|
||||
"description": "Reason new message creation is blocked"
|
||||
},
|
||||
"contact_last_seen_at": {
|
||||
"type": "number",
|
||||
"description": "The last activity at of the contact"
|
||||
@@ -1500,7 +1524,33 @@
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"description": "The conversation object"
|
||||
"description": "The conversation object",
|
||||
"properties": {
|
||||
"message_limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of messages allowed in the conversation"
|
||||
},
|
||||
"message_limit_reached": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the conversation has reached the message limit"
|
||||
},
|
||||
"message_creation_locked": {
|
||||
"type": "boolean",
|
||||
"description": "Whether new messages are currently blocked for the conversation"
|
||||
},
|
||||
"message_creation_lock_reason": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"enum": [
|
||||
"message_limit",
|
||||
"manual",
|
||||
null
|
||||
],
|
||||
"description": "Reason new message creation is blocked"
|
||||
}
|
||||
}
|
||||
},
|
||||
"attachment": {
|
||||
"type": [
|
||||
@@ -6881,6 +6931,30 @@
|
||||
"type": "integer",
|
||||
"description": "Timestamp of last activity"
|
||||
},
|
||||
"message_limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of messages allowed in the conversation"
|
||||
},
|
||||
"message_limit_reached": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the conversation has reached the message limit"
|
||||
},
|
||||
"message_creation_locked": {
|
||||
"type": "boolean",
|
||||
"description": "Whether new messages are currently blocked for the conversation"
|
||||
},
|
||||
"message_creation_lock_reason": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"enum": [
|
||||
"message_limit",
|
||||
"manual",
|
||||
null
|
||||
],
|
||||
"description": "Reason new message creation is blocked"
|
||||
},
|
||||
"contact_inbox": {
|
||||
"type": "object",
|
||||
"description": "Contact inbox details",
|
||||
|
||||
@@ -660,6 +660,30 @@
|
||||
"type": "boolean",
|
||||
"description": "Whether the conversation can be replied to"
|
||||
},
|
||||
"message_limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of messages allowed in the conversation"
|
||||
},
|
||||
"message_limit_reached": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the conversation has reached the message limit"
|
||||
},
|
||||
"message_creation_locked": {
|
||||
"type": "boolean",
|
||||
"description": "Whether new messages are currently blocked for the conversation"
|
||||
},
|
||||
"message_creation_lock_reason": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"enum": [
|
||||
"message_limit",
|
||||
"manual",
|
||||
null
|
||||
],
|
||||
"description": "Reason new message creation is blocked"
|
||||
},
|
||||
"contact_last_seen_at": {
|
||||
"type": "number",
|
||||
"description": "The last activity at of the contact"
|
||||
@@ -915,7 +939,33 @@
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"description": "The conversation object"
|
||||
"description": "The conversation object",
|
||||
"properties": {
|
||||
"message_limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of messages allowed in the conversation"
|
||||
},
|
||||
"message_limit_reached": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the conversation has reached the message limit"
|
||||
},
|
||||
"message_creation_locked": {
|
||||
"type": "boolean",
|
||||
"description": "Whether new messages are currently blocked for the conversation"
|
||||
},
|
||||
"message_creation_lock_reason": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"enum": [
|
||||
"message_limit",
|
||||
"manual",
|
||||
null
|
||||
],
|
||||
"description": "Reason new message creation is blocked"
|
||||
}
|
||||
}
|
||||
},
|
||||
"attachment": {
|
||||
"type": [
|
||||
@@ -6296,6 +6346,30 @@
|
||||
"type": "integer",
|
||||
"description": "Timestamp of last activity"
|
||||
},
|
||||
"message_limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of messages allowed in the conversation"
|
||||
},
|
||||
"message_limit_reached": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the conversation has reached the message limit"
|
||||
},
|
||||
"message_creation_locked": {
|
||||
"type": "boolean",
|
||||
"description": "Whether new messages are currently blocked for the conversation"
|
||||
},
|
||||
"message_creation_lock_reason": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"enum": [
|
||||
"message_limit",
|
||||
"manual",
|
||||
null
|
||||
],
|
||||
"description": "Reason new message creation is blocked"
|
||||
},
|
||||
"contact_inbox": {
|
||||
"type": "object",
|
||||
"description": "Contact inbox details",
|
||||
|
||||
@@ -1421,6 +1421,30 @@
|
||||
"type": "boolean",
|
||||
"description": "Whether the conversation can be replied to"
|
||||
},
|
||||
"message_limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of messages allowed in the conversation"
|
||||
},
|
||||
"message_limit_reached": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the conversation has reached the message limit"
|
||||
},
|
||||
"message_creation_locked": {
|
||||
"type": "boolean",
|
||||
"description": "Whether new messages are currently blocked for the conversation"
|
||||
},
|
||||
"message_creation_lock_reason": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"enum": [
|
||||
"message_limit",
|
||||
"manual",
|
||||
null
|
||||
],
|
||||
"description": "Reason new message creation is blocked"
|
||||
},
|
||||
"contact_last_seen_at": {
|
||||
"type": "number",
|
||||
"description": "The last activity at of the contact"
|
||||
@@ -1676,7 +1700,33 @@
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"description": "The conversation object"
|
||||
"description": "The conversation object",
|
||||
"properties": {
|
||||
"message_limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of messages allowed in the conversation"
|
||||
},
|
||||
"message_limit_reached": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the conversation has reached the message limit"
|
||||
},
|
||||
"message_creation_locked": {
|
||||
"type": "boolean",
|
||||
"description": "Whether new messages are currently blocked for the conversation"
|
||||
},
|
||||
"message_creation_lock_reason": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"enum": [
|
||||
"message_limit",
|
||||
"manual",
|
||||
null
|
||||
],
|
||||
"description": "Reason new message creation is blocked"
|
||||
}
|
||||
}
|
||||
},
|
||||
"attachment": {
|
||||
"type": [
|
||||
@@ -7057,6 +7107,30 @@
|
||||
"type": "integer",
|
||||
"description": "Timestamp of last activity"
|
||||
},
|
||||
"message_limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of messages allowed in the conversation"
|
||||
},
|
||||
"message_limit_reached": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the conversation has reached the message limit"
|
||||
},
|
||||
"message_creation_locked": {
|
||||
"type": "boolean",
|
||||
"description": "Whether new messages are currently blocked for the conversation"
|
||||
},
|
||||
"message_creation_lock_reason": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"enum": [
|
||||
"message_limit",
|
||||
"manual",
|
||||
null
|
||||
],
|
||||
"description": "Reason new message creation is blocked"
|
||||
},
|
||||
"contact_inbox": {
|
||||
"type": "object",
|
||||
"description": "Contact inbox details",
|
||||
|
||||
Reference in New Issue
Block a user