fix: captain template message conflict (#13048)

Co-authored-by: aakashb95 <aakash@chatwoot.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
This commit is contained in:
Aakash Bakhle
2025-12-15 15:47:26 +05:30
committed by GitHub
parent 26b4a24f11
commit 3fce56c98f
7 changed files with 506 additions and 0 deletions

View File

@@ -1,6 +1,14 @@
class MessageTemplates::Template::OutOfOffice
pattr_initialize [:conversation!]
def self.perform_if_applicable(conversation)
inbox = conversation.inbox
return unless inbox.out_of_office?
return if inbox.out_of_office_message.blank?
new(conversation: conversation).perform
end
def perform
ActiveRecord::Base.transaction do
conversation.messages.create!(out_of_office_message_params)

View File

@@ -87,10 +87,15 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
I18n.with_locale(@assistant.account.locale) do
create_handoff_message
@conversation.bot_handoff!
send_out_of_office_message_if_applicable
end
end
end
def send_out_of_office_message_if_applicable
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(@conversation)
end
def create_handoff_message
create_outgoing_message(
@assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff')

View File

@@ -9,6 +9,24 @@ module Enterprise::MessageTemplates::HookExecutionService
schedule_captain_response
end
def should_send_greeting?
return false if captain_handling_conversation?
super
end
def should_send_out_of_office_message?
return false if captain_handling_conversation?
super
end
def should_send_email_collect?
return false if captain_handling_conversation?
super
end
private
def schedule_captain_response
@@ -46,5 +64,14 @@ module Enterprise::MessageTemplates::HookExecutionService
content: 'Transferring to another agent for further assistance.'
)
conversation.bot_handoff!
send_out_of_office_message_after_handoff
end
def send_out_of_office_message_after_handoff
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
end
def captain_handling_conversation?
conversation.pending? && inbox.respond_to?(:captain_assistant) && inbox.captain_assistant.present?
end
end

View File

@@ -36,6 +36,13 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
# Trigger the bot handoff (sets status to open + dispatches events)
conversation.bot_handoff!
# Send out of office message if applicable (since template messages were suppressed while Captain was handling)
send_out_of_office_message_if_applicable(conversation)
end
def send_out_of_office_message_if_applicable(conversation)
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
end
# TODO: Future enhancement - Add team assignment capability

View File

@@ -229,4 +229,106 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
expect(described_class::MAX_MESSAGE_LENGTH).to eq(10_000)
end
end
describe 'out of office message after handoff' do
let(:conversation) { create(:conversation, inbox: inbox, account: account, status: :pending) }
let(:mock_llm_chat_service) { instance_double(Captain::Llm::AssistantChatService) }
before do
create(:message, conversation: conversation, content: 'Hello', message_type: :incoming)
allow(Captain::Llm::AssistantChatService).to receive(:new).and_return(mock_llm_chat_service)
allow(account).to receive(:feature_enabled?).and_return(false)
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(false)
end
context 'when handoff occurs outside business hours' do
before do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed. Please leave your email.'
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'conversation_handoff' })
end
it 'sends out of office message after handoff' do
expect do
described_class.perform_now(conversation, assistant)
end.to change { conversation.messages.template.count }.by(1)
expect(conversation.reload.status).to eq('open')
ooo_message = conversation.messages.template.last
expect(ooo_message.content).to eq('We are currently closed. Please leave your email.')
end
end
context 'when handoff occurs within business hours' do
before do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed.'
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
open_all_day: true,
closed_all_day: false
)
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'conversation_handoff' })
end
it 'does not send out of office message after handoff' do
expect do
described_class.perform_now(conversation, assistant)
end.not_to(change { conversation.messages.template.count })
expect(conversation.reload.status).to eq('open')
end
end
context 'when handoff occurs due to error outside business hours' do
before do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed.'
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
allow(mock_llm_chat_service).to receive(:generate_response).and_raise(StandardError, 'API error')
end
it 'sends out of office message after error-triggered handoff' do
expect do
described_class.perform_now(conversation, assistant)
end.to change { conversation.messages.template.count }.by(1)
expect(conversation.reload.status).to eq('open')
ooo_message = conversation.messages.template.last
expect(ooo_message.content).to eq('We are currently closed.')
end
end
context 'when no out of office message is configured' do
before do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: nil
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'conversation_handoff' })
end
it 'does not send out of office message' do
expect do
described_class.perform_now(conversation, assistant)
end.not_to(change { conversation.messages.template.count })
end
end
end
end

View File

@@ -163,4 +163,66 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do
expect(tool.active?).to be true
end
end
describe 'out of office message after handoff' do
context 'when outside business hours' do
before do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed. Please leave your email.'
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
end
it 'sends out of office message after handoff' do
expect do
tool.perform(tool_context, reason: 'Customer needs help')
end.to change { conversation.messages.template.count }.by(1)
ooo_message = conversation.messages.template.last
expect(ooo_message.content).to eq('We are currently closed. Please leave your email.')
end
end
context 'when within business hours' do
before do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed.'
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
open_all_day: true,
closed_all_day: false
)
end
it 'does not send out of office message after handoff' do
expect do
tool.perform(tool_context, reason: 'Customer needs help')
end.not_to(change { conversation.messages.template.count })
end
end
context 'when no out of office message is configured' do
before do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: nil
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
end
it 'does not send out of office message' do
expect do
tool.perform(tool_context, reason: 'Customer needs help')
end.not_to(change { conversation.messages.template.count })
end
end
end
end

View File

@@ -0,0 +1,295 @@
require 'rails_helper'
RSpec.describe MessageTemplates::HookExecutionService do
let(:account) { create(:account, custom_attributes: { plan_name: 'startups' }) }
let(:inbox) { create(:inbox, account: account) }
let(:contact) { create(:contact, account: account) }
let(:conversation) { create(:conversation, inbox: inbox, account: account, contact: contact, status: :pending) }
let(:assistant) { create(:captain_assistant, account: account) }
before do
create(:captain_inbox, captain_assistant: assistant, inbox: inbox)
end
context 'when captain assistant is configured' do
context 'when within business hours' do
before do
inbox.update!(working_hours_enabled: true)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
open_all_day: true,
closed_all_day: false
)
end
it 'schedules captain response job for incoming messages on pending conversations' do
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
create(:message, conversation: conversation, message_type: :incoming)
end
end
context 'when outside business hours' do
before do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed'
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
end
it 'schedules captain response job outside business hours (Captain always responds when configured)' do
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
create(:message, conversation: conversation, message_type: :incoming)
end
it 'performs captain handoff when quota is exceeded (OOO template will kick in after handoff)' do
account.update!(
limits: { 'captain_responses' => 100 },
custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 100)
)
create(:message, conversation: conversation, message_type: :incoming)
expect(conversation.reload.status).to eq('open')
end
it 'does not send out of office message when Captain is handling' do
out_of_office_service = instance_double(MessageTemplates::Template::OutOfOffice)
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
allow(out_of_office_service).to receive(:perform).and_return(true)
create(:message, conversation: conversation, message_type: :incoming)
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
end
end
context 'when business hours are not enabled' do
before do
inbox.update!(working_hours_enabled: false)
end
it 'schedules captain response job regardless of time' do
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
create(:message, conversation: conversation, message_type: :incoming)
end
end
context 'when captain quota is exceeded within business hours' do
before do
inbox.update!(working_hours_enabled: true)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
open_all_day: true,
closed_all_day: false
)
account.update!(
limits: { 'captain_responses' => 100 },
custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 100)
)
end
it 'performs handoff within business hours when quota exceeded' do
create(:message, conversation: conversation, message_type: :incoming)
expect(conversation.reload.status).to eq('open')
end
end
end
context 'when no captain assistant is configured' do
before do
CaptainInbox.where(inbox: inbox).destroy_all
end
it 'does not schedule captain response job' do
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)
create(:message, conversation: conversation, message_type: :incoming)
end
end
context 'when conversation is not pending' do
before do
conversation.update!(status: :open)
end
it 'does not schedule captain response job' do
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)
create(:message, conversation: conversation, message_type: :incoming)
end
end
context 'when message is outgoing' do
it 'does not schedule captain response job' do
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)
create(:message, conversation: conversation, message_type: :outgoing)
end
end
context 'when greeting and out of office messages with Captain enabled' do
context 'when conversation is pending (Captain is handling)' do
before do
conversation.update!(status: :pending)
end
it 'does not create greeting message in conversation' do
inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false)
expect do
create(:message, conversation: conversation, message_type: :incoming)
end.not_to(change { conversation.reload.messages.template.count })
end
it 'does not create out of office message in conversation' do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed',
enable_email_collect: false
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
expect do
create(:message, conversation: conversation, message_type: :incoming)
end.not_to(change { conversation.reload.messages.template.count })
end
end
context 'when conversation is open (transferred to agent)' do
before do
conversation.update!(status: :open)
end
it 'creates greeting message in conversation' do
inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false)
expect do
create(:message, conversation: conversation, message_type: :incoming)
end.to change { conversation.reload.messages.template.count }.by(1)
greeting_message = conversation.reload.messages.template.last
expect(greeting_message.content).to eq('Hello! How can we help you?')
end
it 'creates out of office message when outside business hours' do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed',
enable_email_collect: false
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
expect do
create(:message, conversation: conversation, message_type: :incoming)
end.to change { conversation.reload.messages.template.count }.by(1)
out_of_office_message = conversation.reload.messages.template.last
expect(out_of_office_message.content).to eq('We are currently closed')
end
end
end
context 'when Captain is not configured' do
before do
CaptainInbox.where(inbox: inbox).destroy_all
end
it 'creates greeting message in conversation' do
inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false)
expect do
create(:message, conversation: conversation, message_type: :incoming)
end.to change { conversation.reload.messages.template.count }.by(1)
greeting_message = conversation.reload.messages.template.last
expect(greeting_message.content).to eq('Hello! How can we help you?')
end
it 'creates out of office message when outside business hours' do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed',
enable_email_collect: false
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
expect do
create(:message, conversation: conversation, message_type: :incoming)
end.to change { conversation.reload.messages.template.count }.by(1)
out_of_office_message = conversation.reload.messages.template.last
expect(out_of_office_message.content).to eq('We are currently closed')
end
end
context 'when Captain quota is exceeded and handoff happens' do
before do
account.update!(
limits: { 'captain_responses' => 100 },
custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 100)
)
end
context 'when outside business hours' do
before do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed. Please leave your email.',
enable_email_collect: false
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
end
it 'sends out of office message after handoff due to quota exceeded' do
expect do
create(:message, conversation: conversation, message_type: :incoming)
end.to change { conversation.messages.template.count }.by(1)
expect(conversation.reload.status).to eq('open')
ooo_message = conversation.messages.template.last
expect(ooo_message.content).to eq('We are currently closed. Please leave your email.')
end
end
context 'when within business hours' do
before do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed.',
enable_email_collect: false
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
open_all_day: true,
closed_all_day: false
)
end
it 'does not send out of office message after handoff' do
expect do
create(:message, conversation: conversation, message_type: :incoming)
end.not_to(change { conversation.messages.template.count })
expect(conversation.reload.status).to eq('open')
end
end
end
end