From 3fce56c98fc10960693bdc4895a02aec7aa482dd Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Mon, 15 Dec 2025 15:47:26 +0530 Subject: [PATCH] fix: captain template message conflict (#13048) Co-authored-by: aakashb95 Co-authored-by: Shivam Mishra Co-authored-by: Vishnu Narayanan --- .../template/out_of_office.rb | 8 + .../conversation/response_builder_job.rb | 5 + .../hook_execution_service.rb | 27 ++ enterprise/lib/captain/tools/handoff_tool.rb | 7 + .../conversation/response_builder_job_spec.rb | 102 ++++++ .../lib/captain/tools/handoff_tool_spec.rb | 62 ++++ .../hook_execution_service_spec.rb | 295 ++++++++++++++++++ 7 files changed, 506 insertions(+) create mode 100644 spec/enterprise/services/enterprise/message_templates/hook_execution_service_spec.rb diff --git a/app/services/message_templates/template/out_of_office.rb b/app/services/message_templates/template/out_of_office.rb index b6fd1b7a1..f3c8c24fa 100644 --- a/app/services/message_templates/template/out_of_office.rb +++ b/app/services/message_templates/template/out_of_office.rb @@ -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) diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb index f218ff68c..9f8acc474 100644 --- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb +++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb @@ -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') diff --git a/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb b/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb index ac1c3a95d..5c9a8a184 100644 --- a/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb +++ b/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb @@ -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 diff --git a/enterprise/lib/captain/tools/handoff_tool.rb b/enterprise/lib/captain/tools/handoff_tool.rb index 49f7c5a65..797f248ff 100644 --- a/enterprise/lib/captain/tools/handoff_tool.rb +++ b/enterprise/lib/captain/tools/handoff_tool.rb @@ -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 diff --git a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb index 565fc26cf..08a77626a 100644 --- a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb +++ b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb @@ -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 diff --git a/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb b/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb index 16b46c08a..ef56ae3bb 100644 --- a/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb +++ b/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb @@ -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 diff --git a/spec/enterprise/services/enterprise/message_templates/hook_execution_service_spec.rb b/spec/enterprise/services/enterprise/message_templates/hook_execution_service_spec.rb new file mode 100644 index 000000000..9074baa57 --- /dev/null +++ b/spec/enterprise/services/enterprise/message_templates/hook_execution_service_spec.rb @@ -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