feat(conversations): add message creation limit lock

This commit is contained in:
Sony Mathew
2026-07-02 00:28:31 +05:30
parent 926a9d8a69
commit 48e7985b0f
73 changed files with 1691 additions and 58 deletions
@@ -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'
+51
View File
@@ -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,
+45
View File
@@ -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
+40
View File
@@ -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