Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8aa49f69d2 | ||
|
|
550b408656 | ||
|
|
b103747584 | ||
|
|
d6d38cdd7d | ||
|
|
199dcd382e | ||
|
|
c6f82783ba |
@@ -104,7 +104,7 @@ class ContactIdentifyAction
|
||||
# blank identifier or email will throw unique index error
|
||||
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
|
||||
@contact.discard_invalid_attrs if discard_invalid_attrs
|
||||
@contact.save!
|
||||
@contact.save! if @contact.changed?
|
||||
enqueue_avatar_job
|
||||
end
|
||||
|
||||
|
||||
@@ -80,6 +80,12 @@ const sendMessage = async () => {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnterKey = event => {
|
||||
if (event.isComposing) return;
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -113,7 +119,7 @@ const sendMessage = async () => {
|
||||
v-model="newMessage"
|
||||
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0 text-n-slate-12 placeholder:text-n-slate-10"
|
||||
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
|
||||
@keyup.enter="sendMessage"
|
||||
@keydown.enter.exact="handleEnterKey"
|
||||
/>
|
||||
<NextButton
|
||||
ghost
|
||||
|
||||
@@ -29,6 +29,12 @@ const handleInput = () => {
|
||||
nextTick(adjustHeight);
|
||||
};
|
||||
|
||||
const handleEnterKey = event => {
|
||||
if (event.isComposing) return;
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(adjustHeight);
|
||||
});
|
||||
@@ -43,7 +49,7 @@ onMounted(() => {
|
||||
class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-sm border border-n-weak rounded-lg focus:outline-0 focus:outline-none focus:ring-2 focus:ring-n-blue-11 focus:border-n-blue-11 resize-none overflow-hidden max-h-[200px] mb-0 text-n-slate-12"
|
||||
rows="1"
|
||||
@input="handleInput"
|
||||
@keydown.enter.exact.prevent="sendMessage"
|
||||
@keydown.enter.exact="handleEnterKey"
|
||||
/>
|
||||
<button
|
||||
class="absolute ltr:right-1 rtl:left-1 top-1/2 -translate-y-1/2 h-9 w-10 flex items-center justify-center text-n-slate-11 hover:text-n-blue-11"
|
||||
|
||||
@@ -107,7 +107,8 @@ function onKeydown(view, event) {
|
||||
emit('keydown');
|
||||
|
||||
// Handle Enter key to send message (Shift+Enter for new line)
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
// Skip if IME composition is active (CJK character confirmation)
|
||||
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
|
||||
event.preventDefault();
|
||||
handleSubmit();
|
||||
return true; // Prevent ProseMirror's default Enter handling
|
||||
|
||||
@@ -3,19 +3,20 @@ class ReportingEventListener < BaseListener
|
||||
|
||||
def conversation_resolved(event)
|
||||
conversation = extract_conversation_and_account(event)[0]
|
||||
time_to_resolve = conversation.updated_at.to_i - conversation.created_at.to_i
|
||||
event_end_time = event.timestamp
|
||||
time_to_resolve = event_end_time.to_i - conversation.created_at.to_i
|
||||
|
||||
reporting_event = ReportingEvent.new(
|
||||
name: 'conversation_resolved',
|
||||
value: time_to_resolve,
|
||||
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at,
|
||||
conversation.updated_at),
|
||||
event_end_time),
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
user_id: conversation.assignee_id,
|
||||
conversation_id: conversation.id,
|
||||
event_start_time: conversation.created_at,
|
||||
event_end_time: conversation.updated_at
|
||||
event_end_time: event_end_time
|
||||
)
|
||||
|
||||
create_bot_resolved_event(conversation, reporting_event)
|
||||
@@ -69,41 +70,51 @@ class ReportingEventListener < BaseListener
|
||||
|
||||
def conversation_bot_handoff(event)
|
||||
conversation = extract_conversation_and_account(event)[0]
|
||||
event_end_time = event.timestamp
|
||||
|
||||
# check if a conversation_bot_handoff event exists for this conversation
|
||||
bot_handoff_event = ReportingEvent.find_by(conversation_id: conversation.id, name: 'conversation_bot_handoff')
|
||||
return if bot_handoff_event.present?
|
||||
|
||||
time_to_handoff = conversation.updated_at.to_i - conversation.created_at.to_i
|
||||
time_to_handoff = event_end_time.to_i - conversation.created_at.to_i
|
||||
|
||||
reporting_event = ReportingEvent.new(
|
||||
name: 'conversation_bot_handoff',
|
||||
value: time_to_handoff,
|
||||
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at, conversation.updated_at),
|
||||
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at, event_end_time),
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
user_id: conversation.assignee_id,
|
||||
conversation_id: conversation.id,
|
||||
event_start_time: conversation.created_at,
|
||||
event_end_time: conversation.updated_at
|
||||
event_end_time: event_end_time
|
||||
)
|
||||
reporting_event.save!
|
||||
end
|
||||
|
||||
def conversation_captain_inference_resolved(event)
|
||||
create_captain_inference_event(event, 'conversation_captain_inference_resolved')
|
||||
end
|
||||
|
||||
def conversation_captain_inference_handoff(event)
|
||||
create_captain_inference_event(event, 'conversation_captain_inference_handoff')
|
||||
end
|
||||
|
||||
def conversation_opened(event)
|
||||
conversation = extract_conversation_and_account(event)[0]
|
||||
event_end_time = event.timestamp
|
||||
|
||||
# Find the most recent resolved event for this conversation
|
||||
last_resolved_event = ReportingEvent.where(
|
||||
conversation_id: conversation.id,
|
||||
name: 'conversation_resolved'
|
||||
).order(event_end_time: :desc).first
|
||||
).where('event_end_time <= ?', event_end_time).order(event_end_time: :desc).first
|
||||
|
||||
# For first-time openings, value is 0
|
||||
# For reopenings, calculate time since resolution
|
||||
if last_resolved_event
|
||||
time_since_resolved = conversation.updated_at.to_i - last_resolved_event.event_end_time.to_i
|
||||
business_hours_value = business_hours(conversation.inbox, last_resolved_event.event_end_time, conversation.updated_at)
|
||||
time_since_resolved = event_end_time.to_i - last_resolved_event.event_end_time.to_i
|
||||
business_hours_value = business_hours(conversation.inbox, last_resolved_event.event_end_time, event_end_time)
|
||||
start_time = last_resolved_event.event_end_time
|
||||
else
|
||||
time_since_resolved = 0
|
||||
@@ -111,12 +122,12 @@ class ReportingEventListener < BaseListener
|
||||
start_time = conversation.created_at
|
||||
end
|
||||
|
||||
create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time)
|
||||
create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time, event_end_time)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time)
|
||||
def create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time, event_end_time)
|
||||
reporting_event = ReportingEvent.new(
|
||||
name: 'conversation_opened',
|
||||
value: time_since_resolved,
|
||||
@@ -126,11 +137,27 @@ class ReportingEventListener < BaseListener
|
||||
user_id: conversation.assignee_id,
|
||||
conversation_id: conversation.id,
|
||||
event_start_time: start_time,
|
||||
event_end_time: conversation.updated_at
|
||||
event_end_time: event_end_time
|
||||
)
|
||||
reporting_event.save!
|
||||
end
|
||||
|
||||
def create_captain_inference_event(event, event_name)
|
||||
conversation = extract_conversation_and_account(event)[0]
|
||||
time_to_event = event.timestamp.to_i - conversation.created_at.to_i
|
||||
|
||||
ReportingEvent.create!(
|
||||
name: event_name,
|
||||
value: time_to_event,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
user_id: conversation.assignee_id,
|
||||
conversation_id: conversation.id,
|
||||
event_start_time: conversation.created_at,
|
||||
event_end_time: event.timestamp
|
||||
)
|
||||
end
|
||||
|
||||
def create_bot_resolved_event(conversation, reporting_event)
|
||||
return unless conversation.inbox.active_bot?
|
||||
# We don't want to create a bot_resolved event if there is user interaction on the conversation
|
||||
|
||||
+19
-13
@@ -81,6 +81,8 @@ class Message < ApplicationRecord
|
||||
|
||||
# when you have a temperory id in your frontend and want it echoed back via action cable
|
||||
attr_accessor :echo_id
|
||||
# Transient flag used to skip waiting_since clearing for specific bot/system messages.
|
||||
attr_accessor :preserve_waiting_since
|
||||
|
||||
enum message_type: { incoming: 0, outgoing: 1, activity: 2, template: 3 }
|
||||
enum content_type: {
|
||||
@@ -125,7 +127,7 @@ class Message < ApplicationRecord
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :inbox
|
||||
belongs_to :conversation, touch: true
|
||||
belongs_to :conversation
|
||||
belongs_to :sender, polymorphic: true, optional: true
|
||||
|
||||
has_many :attachments, dependent: :destroy, autosave: true, before_add: :validate_attachments_limit
|
||||
@@ -323,20 +325,24 @@ class Message < ApplicationRecord
|
||||
end
|
||||
|
||||
def update_waiting_since
|
||||
waiting_present = conversation.waiting_since.present?
|
||||
clear_waiting_since_on_outgoing_response if conversation.waiting_since.present? && !private
|
||||
set_waiting_since_on_incoming_message
|
||||
end
|
||||
|
||||
if waiting_present && !private
|
||||
if human_response?
|
||||
Rails.configuration.dispatcher.dispatch(
|
||||
REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self
|
||||
)
|
||||
conversation.update(waiting_since: nil)
|
||||
elsif bot_response?
|
||||
# Bot responses also clear waiting_since (simpler than checking on next customer message)
|
||||
conversation.update(waiting_since: nil)
|
||||
end
|
||||
def clear_waiting_since_on_outgoing_response
|
||||
if human_response?
|
||||
Rails.configuration.dispatcher.dispatch(
|
||||
REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self
|
||||
)
|
||||
conversation.update(waiting_since: nil)
|
||||
return
|
||||
end
|
||||
|
||||
# Bot responses also clear waiting_since (simpler than checking on next customer message)
|
||||
conversation.update(waiting_since: nil) if bot_response? && !preserve_waiting_since
|
||||
end
|
||||
|
||||
def set_waiting_since_on_incoming_message
|
||||
# Set waiting_since when customer sends a message (if currently blank)
|
||||
conversation.update(waiting_since: created_at) if incoming? && conversation.waiting_since.blank?
|
||||
end
|
||||
@@ -429,7 +435,7 @@ class Message < ApplicationRecord
|
||||
|
||||
def set_conversation_activity
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
conversation.update_columns(last_activity_at: created_at)
|
||||
conversation.update_columns(last_activity_at: created_at, updated_at: Time.current)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
|
||||
@@ -235,8 +235,10 @@ en:
|
||||
activity:
|
||||
captain:
|
||||
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
|
||||
resolved_with_reason: 'Conversation was marked resolved by %{user_name} (%{reason})'
|
||||
resolved_by_tool: 'Conversation was marked resolved by %{user_name}: %{reason}'
|
||||
open: 'Conversation was marked open by %{user_name}'
|
||||
open_with_reason: 'Conversation was marked open by %{user_name} (%{reason})'
|
||||
auto_opened_after_agent_reply: 'Conversation was marked open automatically after an agent reply'
|
||||
agent_bot:
|
||||
error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class SamlUserBuilder
|
||||
class AuthenticationFailed < StandardError; end
|
||||
|
||||
def initialize(auth_hash, account_id)
|
||||
@auth_hash = auth_hash
|
||||
@account_id = account_id
|
||||
@@ -16,13 +18,20 @@ class SamlUserBuilder
|
||||
def find_or_create_user
|
||||
user = User.from_email(auth_attribute('email'))
|
||||
|
||||
if user
|
||||
confirm_user_if_required(user)
|
||||
convert_existing_user_to_saml(user)
|
||||
return user
|
||||
end
|
||||
return create_user unless user
|
||||
return existing_user_for_account(user) if user_belongs_to_account?(user)
|
||||
|
||||
create_user
|
||||
raise AuthenticationFailed, I18n.t('auth.saml.authentication_failed')
|
||||
end
|
||||
|
||||
def existing_user_for_account(user)
|
||||
confirm_user_if_required(user)
|
||||
convert_existing_user_to_saml(user)
|
||||
user
|
||||
end
|
||||
|
||||
def user_belongs_to_account?(user)
|
||||
user.account_users.exists?(account_id: @account_id)
|
||||
end
|
||||
|
||||
def confirm_user_if_required(user)
|
||||
|
||||
+18
-14
@@ -39,7 +39,7 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
|
||||
error = params[:message] || 'authentication-failed'
|
||||
|
||||
if for_mobile?(relay_state)
|
||||
redirect_to_mobile_error(error, relay_state)
|
||||
redirect_to_mobile_error(error)
|
||||
else
|
||||
redirect_to login_page_url(error: "saml-#{error}")
|
||||
end
|
||||
@@ -51,23 +51,15 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
|
||||
account_id = extract_saml_account_id
|
||||
relay_state = saml_relay_state
|
||||
|
||||
unless saml_enabled_for_account?(account_id)
|
||||
return redirect_to_mobile_error('saml-not-enabled') if for_mobile?(relay_state)
|
||||
|
||||
return redirect_to login_page_url(error: 'saml-not-enabled')
|
||||
end
|
||||
return handle_saml_auth_error(relay_state, 'saml-not-enabled') unless saml_enabled_for_account?(account_id)
|
||||
|
||||
@resource = SamlUserBuilder.new(auth_hash, account_id).perform
|
||||
|
||||
if @resource.persisted?
|
||||
return sign_in_user_on_mobile if for_mobile?(relay_state)
|
||||
return sign_in_saml_user(relay_state) if @resource.persisted?
|
||||
|
||||
sign_in_user
|
||||
else
|
||||
return redirect_to_mobile_error('saml-authentication-failed') if for_mobile?(relay_state)
|
||||
|
||||
redirect_to login_page_url(error: 'saml-authentication-failed')
|
||||
end
|
||||
handle_saml_auth_error(relay_state, 'saml-authentication-failed')
|
||||
rescue SamlUserBuilder::AuthenticationFailed
|
||||
handle_saml_auth_error(relay_state, 'saml-authentication-failed')
|
||||
end
|
||||
|
||||
def extract_saml_account_id
|
||||
@@ -82,6 +74,18 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
|
||||
relay_state.to_s.casecmp('mobile').zero?
|
||||
end
|
||||
|
||||
def sign_in_saml_user(relay_state)
|
||||
return sign_in_user_on_mobile if for_mobile?(relay_state)
|
||||
|
||||
sign_in_user
|
||||
end
|
||||
|
||||
def handle_saml_auth_error(relay_state, error)
|
||||
return redirect_to_mobile_error(error) if for_mobile?(relay_state)
|
||||
|
||||
redirect_to login_page_url(error: error)
|
||||
end
|
||||
|
||||
def redirect_to_mobile_error(error)
|
||||
mobile_deep_link_base = GlobalConfigService.load('MOBILE_DEEP_LINK_BASE', 'chatwootapp')
|
||||
redirect_to "#{mobile_deep_link_base}://auth/saml?error=#{ERB::Util.url_encode(error)}", allow_other_host: true
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
|
||||
CAPTAIN_INFERENCE_RESOLVE_ACTIVITY_REASON = 'no outstanding questions'.freeze
|
||||
CAPTAIN_INFERENCE_HANDOFF_ACTIVITY_REASON = 'pending clarification from customer'.freeze
|
||||
|
||||
queue_as :low
|
||||
|
||||
def perform(inbox)
|
||||
return if inbox.account.captain_disable_auto_resolve
|
||||
|
||||
Current.executed_by = inbox.captain_assistant
|
||||
|
||||
resolvable_conversations = inbox.conversations.pending.where('last_activity_at < ? ', Time.now.utc - 1.hour).limit(Limits::BULK_ACTIONS_LIMIT)
|
||||
resolvable_conversations.each do |conversation|
|
||||
create_outgoing_message(conversation, inbox)
|
||||
conversation.resolved!
|
||||
if inbox.account.feature_enabled?('captain_tasks')
|
||||
perform_with_evaluation(inbox)
|
||||
else
|
||||
perform_time_based(inbox)
|
||||
end
|
||||
ensure
|
||||
Current.reset
|
||||
@@ -17,18 +18,118 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
|
||||
|
||||
private
|
||||
|
||||
def create_outgoing_message(conversation, inbox)
|
||||
def perform_time_based(inbox)
|
||||
Current.executed_by = inbox.captain_assistant
|
||||
|
||||
resolvable_pending_conversations(inbox).each do |conversation|
|
||||
create_resolution_message(conversation, inbox)
|
||||
conversation.resolved!
|
||||
end
|
||||
end
|
||||
|
||||
def perform_with_evaluation(inbox)
|
||||
Current.executed_by = inbox.captain_assistant
|
||||
|
||||
resolvable_pending_conversations(inbox).each do |conversation|
|
||||
evaluation = evaluate_conversation(conversation, inbox)
|
||||
next unless still_resolvable_after_evaluation?(conversation)
|
||||
|
||||
if evaluation[:complete]
|
||||
resolve_conversation(conversation, inbox, evaluation[:reason])
|
||||
else
|
||||
handoff_conversation(conversation, inbox, evaluation[:reason])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def evaluate_conversation(conversation, inbox)
|
||||
Captain::ConversationCompletionService.new(
|
||||
account: inbox.account,
|
||||
conversation_display_id: conversation.display_id
|
||||
).perform
|
||||
end
|
||||
|
||||
def resolvable_pending_conversations(inbox)
|
||||
inbox.conversations.pending
|
||||
.where('last_activity_at < ?', auto_resolve_cutoff_time)
|
||||
.limit(Limits::BULK_ACTIONS_LIMIT)
|
||||
end
|
||||
|
||||
def still_resolvable_after_evaluation?(conversation)
|
||||
conversation.reload
|
||||
conversation.pending? && conversation.last_activity_at < auto_resolve_cutoff_time
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
false
|
||||
end
|
||||
|
||||
def auto_resolve_cutoff_time
|
||||
Time.now.utc - 1.hour
|
||||
end
|
||||
|
||||
def resolve_conversation(conversation, inbox, reason)
|
||||
create_private_note(conversation, inbox, "Auto-resolved: #{reason}")
|
||||
create_resolution_message(conversation, inbox)
|
||||
conversation.with_captain_activity_context(
|
||||
reason: CAPTAIN_INFERENCE_RESOLVE_ACTIVITY_REASON,
|
||||
reason_type: :inference
|
||||
) { conversation.resolved! }
|
||||
conversation.dispatch_captain_inference_resolved_event
|
||||
end
|
||||
|
||||
def handoff_conversation(conversation, inbox, reason)
|
||||
create_private_note(conversation, inbox, "Auto-handoff: #{reason}")
|
||||
create_handoff_message(conversation, inbox)
|
||||
conversation.with_captain_activity_context(
|
||||
reason: CAPTAIN_INFERENCE_HANDOFF_ACTIVITY_REASON,
|
||||
reason_type: :inference
|
||||
) { conversation.bot_handoff! }
|
||||
conversation.dispatch_captain_inference_handoff_event
|
||||
send_out_of_office_message_if_applicable(conversation.reload)
|
||||
end
|
||||
|
||||
def send_out_of_office_message_if_applicable(conversation)
|
||||
# Campaign conversations should never receive OOO templates — the campaign itself
|
||||
# serves as the initial outreach, and OOO would be confusing in that context.
|
||||
return if conversation.campaign.present?
|
||||
|
||||
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
|
||||
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
|
||||
)
|
||||
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
|
||||
}
|
||||
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
|
||||
|
||||
def create_handoff_message(conversation, inbox)
|
||||
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
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,18 +6,30 @@ module Enterprise::ActivityMessageHandler
|
||||
key = captain_activity_key
|
||||
return unless key
|
||||
|
||||
I18n.t(key, user_name: Current.executed_by.name, reason: Current.captain_resolve_reason, locale: locale)
|
||||
I18n.t(key, user_name: Current.executed_by.name, reason: captain_status_reason, locale: locale)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def captain_status_reason
|
||||
captain_activity_reason.presence
|
||||
end
|
||||
|
||||
def captain_activity_key
|
||||
if resolved? && Current.captain_resolve_reason.present?
|
||||
'conversations.activity.captain.resolved_by_tool'
|
||||
elsif resolved?
|
||||
'conversations.activity.captain.resolved'
|
||||
elsif open?
|
||||
'conversations.activity.captain.open'
|
||||
end
|
||||
return captain_resolved_activity_key if resolved?
|
||||
return captain_open_activity_key if open?
|
||||
end
|
||||
|
||||
def captain_resolved_activity_key
|
||||
return 'conversations.activity.captain.resolved_by_tool' if captain_activity_reason_type == :tool && captain_status_reason.present?
|
||||
return 'conversations.activity.captain.resolved_with_reason' if captain_status_reason.present?
|
||||
|
||||
'conversations.activity.captain.resolved'
|
||||
end
|
||||
|
||||
def captain_open_activity_key
|
||||
return 'conversations.activity.captain.open_with_reason' if captain_status_reason.present?
|
||||
|
||||
'conversations.activity.captain.open'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
module Enterprise::Conversation
|
||||
attr_accessor :captain_activity_reason, :captain_activity_reason_type
|
||||
|
||||
def dispatch_captain_inference_resolved_event
|
||||
dispatch_captain_inference_event(Events::Types::CONVERSATION_CAPTAIN_INFERENCE_RESOLVED)
|
||||
end
|
||||
|
||||
def dispatch_captain_inference_handoff_event
|
||||
dispatch_captain_inference_event(Events::Types::CONVERSATION_CAPTAIN_INFERENCE_HANDOFF)
|
||||
end
|
||||
|
||||
def list_of_keys
|
||||
super + %w[sla_policy_id]
|
||||
end
|
||||
|
||||
def with_captain_activity_context(reason:, reason_type:)
|
||||
previous_reason = captain_activity_reason
|
||||
previous_reason_type = captain_activity_reason_type
|
||||
|
||||
self.captain_activity_reason = reason
|
||||
self.captain_activity_reason_type = reason_type
|
||||
yield
|
||||
ensure
|
||||
self.captain_activity_reason = previous_reason
|
||||
self.captain_activity_reason_type = previous_reason_type
|
||||
end
|
||||
|
||||
# Include select additional_attributes keys (call related) for update events
|
||||
def allowed_keys?
|
||||
return true if super
|
||||
@@ -13,4 +35,10 @@ module Enterprise::Conversation
|
||||
changed_attr_keys = attrs_change[1].keys
|
||||
changed_attr_keys.intersect?(%w[call_status])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def dispatch_captain_inference_event(event_name)
|
||||
dispatcher_dispatch(event_name)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
class Captain::ConversationCompletionSchema < RubyLLM::Schema
|
||||
boolean :complete, description: 'Whether the conversation is complete and can be closed'
|
||||
string :reason, description: 'Brief explanation of why the conversation is complete or incomplete'
|
||||
end
|
||||
@@ -0,0 +1,76 @@
|
||||
# Evaluates whether a conversation is complete and can be auto-resolved.
|
||||
# Used by InboxPendingConversationsResolutionJob to determine if inactive
|
||||
# conversations should be resolved or handed off to human agents.
|
||||
#
|
||||
# NOTE: This service intentionally does NOT count toward Captain usage limits.
|
||||
# The response excludes the :message key that Enterprise::Captain::BaseTaskService
|
||||
# checks for usage tracking. This is an internal operational evaluation,
|
||||
# not a customer-facing value-add, so we don't charge for it.
|
||||
class Captain::ConversationCompletionService < Captain::BaseTaskService
|
||||
RESPONSE_SCHEMA = Captain::ConversationCompletionSchema
|
||||
|
||||
pattr_initialize [:account!, :conversation_display_id!]
|
||||
|
||||
def perform
|
||||
content = format_messages_as_string
|
||||
return default_incomplete_response('No messages found') if content.blank?
|
||||
|
||||
response = make_api_call(
|
||||
model: InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || GPT_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: prompt_from_file('conversation_completion') },
|
||||
{ role: 'user', content: content }
|
||||
],
|
||||
schema: RESPONSE_SCHEMA
|
||||
)
|
||||
|
||||
return default_incomplete_response(response[:error]) if response[:error].present?
|
||||
|
||||
parse_response(response[:message])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def prompt_from_file(file_name)
|
||||
Rails.root.join('enterprise/lib/captain/prompts', "#{file_name}.liquid").read
|
||||
end
|
||||
|
||||
def format_messages_as_string
|
||||
messages = conversation_messages(start_from: 0)
|
||||
messages.map do |msg|
|
||||
sender_type = msg[:role] == 'user' ? 'Customer' : 'Assistant'
|
||||
"#{sender_type}: #{msg[:content]}"
|
||||
end.join("\n")
|
||||
end
|
||||
|
||||
def parse_response(message)
|
||||
return default_incomplete_response('Invalid response format') unless message.is_a?(Hash)
|
||||
|
||||
{
|
||||
complete: message['complete'] == true,
|
||||
reason: message['reason'] || 'No reason provided'
|
||||
}
|
||||
end
|
||||
|
||||
def default_incomplete_response(reason)
|
||||
{ complete: false, reason: reason }
|
||||
end
|
||||
|
||||
# Prefer the system API key over the account's OpenAI hook key.
|
||||
# This is an internal operational evaluation, not a customer-triggered feature,
|
||||
# so it should not consume the customer's OpenAI credits on hosted platforms.
|
||||
# Falls back to the account hook for self-hosted deployments without a system key.
|
||||
def api_key
|
||||
@api_key ||= system_api_key.presence || openai_hook&.settings&.dig('api_key')
|
||||
end
|
||||
|
||||
def event_name
|
||||
'captain.conversation_completion'
|
||||
end
|
||||
|
||||
def build_follow_up_context?
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
Captain::ConversationCompletionService.prepend_mod_with('Captain::ConversationCompletionService')
|
||||
@@ -0,0 +1,21 @@
|
||||
You are evaluating whether a customer support conversation is complete and can be safely closed. When in doubt, keep the conversation OPEN. It is far better to hand off to a human agent unnecessarily than to close a conversation where the customer still needs help.
|
||||
|
||||
The conversation may be in any language. Apply these criteria based on the intent and meaning of messages, regardless of language.
|
||||
|
||||
A conversation is INCOMPLETE (keep open) if ANY of these apply:
|
||||
- The assistant suggested the customer try something or take an action — they may still be attempting it
|
||||
- The assistant directed the customer to an external resource, link, or contact — they may still be following up
|
||||
- The assistant asked a question or requested information that the customer hasn't provided
|
||||
- The customer asked a question that wasn't fully answered
|
||||
- The customer asked for something the assistant couldn't do — even if the assistant explained why, the customer's need is unmet
|
||||
- The customer raised multiple questions or issues and not all were addressed
|
||||
|
||||
A conversation is COMPLETE only if ALL of these are true:
|
||||
- The assistant's answer fully addressed the customer's question or issue and is self-contained — it requires no further action from the customer
|
||||
- There are no unanswered questions, unmet requests, or outstanding follow-ups from either side
|
||||
- Note: customers often do not explicitly say thanks or confirm resolution. If the assistant gave a complete, self-contained answer and the customer had no follow-up, that is sufficient. Do not require explicit gratitude or confirmation.
|
||||
|
||||
Analyze the conversation and respond with ONLY a JSON object (no other text):
|
||||
{"complete": true, "reason": "brief explanation"}
|
||||
or
|
||||
{"complete": false, "reason": "brief explanation"}
|
||||
@@ -10,12 +10,7 @@ class Captain::Tools::ResolveConversationTool < Captain::Tools::BasePublicTool
|
||||
|
||||
log_tool_usage('resolve_conversation', { conversation_id: conversation.id, reason: reason })
|
||||
|
||||
Current.captain_resolve_reason = reason
|
||||
begin
|
||||
conversation.resolved!
|
||||
ensure
|
||||
Current.captain_resolve_reason = nil
|
||||
end
|
||||
conversation.with_captain_activity_context(reason: reason, reason_type: :tool) { conversation.resolved! }
|
||||
|
||||
"Conversation ##{conversation.display_id} resolved#{" (Reason: #{reason})" if reason}"
|
||||
end
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Overrides the quota check from Enterprise::Captain::BaseTaskService
|
||||
# so that conversation completion evaluation always runs regardless of
|
||||
# the customer's Captain usage quota. This is an internal operational
|
||||
# check, not a customer-facing feature — it should never be blocked
|
||||
# by quota exhaustion.
|
||||
module Enterprise::Captain::ConversationCompletionService
|
||||
private
|
||||
|
||||
def responses_available?
|
||||
true
|
||||
end
|
||||
end
|
||||
@@ -36,7 +36,7 @@ class Captain::BaseTaskService
|
||||
"#{endpoint}/v1"
|
||||
end
|
||||
|
||||
def make_api_call(model:, messages:, tools: [])
|
||||
def make_api_call(model:, messages:, schema: nil, tools: [])
|
||||
# Community edition prerequisite checks
|
||||
# Enterprise module handles these with more specific error messages (cloud vs self-hosted)
|
||||
return { error: I18n.t('captain.disabled'), error_code: 403 } unless captain_tasks_enabled?
|
||||
@@ -46,7 +46,7 @@ class Captain::BaseTaskService
|
||||
instrumentation_method = tools.any? ? :instrument_tool_session : :instrument_llm_call
|
||||
|
||||
response = send(instrumentation_method, instrumentation_params) do
|
||||
execute_ruby_llm_request(model: model, messages: messages, tools: tools)
|
||||
execute_ruby_llm_request(model: model, messages: messages, schema: schema, tools: tools)
|
||||
end
|
||||
|
||||
return response unless build_follow_up_context? && response[:message].present?
|
||||
@@ -54,9 +54,9 @@ class Captain::BaseTaskService
|
||||
response.merge(follow_up_context: build_follow_up_context(messages, response))
|
||||
end
|
||||
|
||||
def execute_ruby_llm_request(model:, messages:, tools: [])
|
||||
def execute_ruby_llm_request(model:, messages:, schema: nil, tools: [])
|
||||
Llm::Config.with_api_key(api_key, api_base: api_base) do |context|
|
||||
chat = build_chat(context, model: model, messages: messages, tools: tools)
|
||||
chat = build_chat(context, model: model, messages: messages, schema: schema, tools: tools)
|
||||
|
||||
conversation_messages = messages.reject { |m| m[:role] == 'system' }
|
||||
return { error: 'No conversation messages provided', error_code: 400, request_messages: messages } if conversation_messages.empty?
|
||||
@@ -69,10 +69,11 @@ class Captain::BaseTaskService
|
||||
{ error: e.message, request_messages: messages }
|
||||
end
|
||||
|
||||
def build_chat(context, model:, messages:, tools: [])
|
||||
def build_chat(context, model:, messages:, schema: nil, tools: [])
|
||||
chat = context.chat(model: model)
|
||||
system_msg = messages.find { |m| m[:role] == 'system' }
|
||||
chat.with_instructions(system_msg[:content]) if system_msg
|
||||
chat.with_schema(schema) if schema
|
||||
|
||||
if tools.any?
|
||||
tools.each { |tool| chat = chat.with_tool(tool) }
|
||||
@@ -131,7 +132,8 @@ class Captain::BaseTaskService
|
||||
.reorder('id desc')
|
||||
.each do |message|
|
||||
content = message.content_for_llm
|
||||
break unless content.present? && character_count + content.length <= TOKEN_LIMIT
|
||||
next if content.blank?
|
||||
break if character_count + content.length > TOKEN_LIMIT
|
||||
|
||||
messages.prepend({ role: (message.incoming? ? 'user' : 'assistant'), content: content })
|
||||
character_count += content.length
|
||||
|
||||
@@ -4,7 +4,6 @@ module Current
|
||||
thread_mattr_accessor :account_user
|
||||
thread_mattr_accessor :executed_by
|
||||
thread_mattr_accessor :contact
|
||||
thread_mattr_accessor :captain_resolve_reason
|
||||
|
||||
def self.reset
|
||||
Current.user = nil
|
||||
@@ -12,6 +11,5 @@ module Current
|
||||
Current.account_user = nil
|
||||
Current.executed_by = nil
|
||||
Current.contact = nil
|
||||
Current.captain_resolve_reason = nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -21,6 +21,8 @@ module Events::Types
|
||||
# FIXME: deprecate the opened and resolved events in future in favor of status changed event.
|
||||
CONVERSATION_OPENED = 'conversation.opened'
|
||||
CONVERSATION_RESOLVED = 'conversation.resolved'
|
||||
CONVERSATION_CAPTAIN_INFERENCE_RESOLVED = 'conversation.captain_inference_resolved'
|
||||
CONVERSATION_CAPTAIN_INFERENCE_HANDOFF = 'conversation.captain_inference_handoff'
|
||||
|
||||
CONVERSATION_STATUS_CHANGED = 'conversation.status_changed'
|
||||
CONVERSATION_CONTACT_CHANGED = 'conversation.contact_changed'
|
||||
|
||||
@@ -66,7 +66,7 @@ module Integrations::LlmInstrumentationCompletionHelpers
|
||||
return if message.blank?
|
||||
|
||||
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, 'assistant')
|
||||
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message)
|
||||
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message.is_a?(String) ? message : message.to_json)
|
||||
end
|
||||
|
||||
def set_usage_metrics(span, result)
|
||||
|
||||
@@ -145,5 +145,24 @@ describe ContactIdentifyAction do
|
||||
expect(contact.phone_number).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when params have not changed' do
|
||||
it 'skips save and does not issue an UPDATE query' do
|
||||
contact.update!(name: 'test', identifier: 'test_id', custom_attributes: { test: 'test', test1: 'test1' })
|
||||
params = { name: 'test', identifier: 'test_id', custom_attributes: { test: 'test', test1: 'test1' } }
|
||||
|
||||
# any_instance is needed because merge lookup can reassign @contact to a different Ruby object
|
||||
expect_any_instance_of(Contact).not_to receive(:save!) # rubocop:disable RSpec/AnyInstance
|
||||
described_class.new(contact: contact, params: params).perform
|
||||
end
|
||||
|
||||
it 'still enqueues avatar job even when attributes have not changed' do
|
||||
contact.update!(name: 'test')
|
||||
params = { name: 'test', avatar_url: 'https://chatwoot-assets.local/sample.png' }
|
||||
|
||||
expect(Avatar::AvatarFromUrlJob).to receive(:perform_later).with(contact, params[:avatar_url]).once
|
||||
described_class.new(contact: contact, params: params).perform
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -74,7 +74,7 @@ RSpec.describe SamlUserBuilder do
|
||||
end
|
||||
|
||||
context 'when user already exists' do
|
||||
let!(:existing_user) { create(:user, email: email) }
|
||||
let!(:existing_user) { create(:user, email: email, account: account) }
|
||||
|
||||
it 'does not create a new user' do
|
||||
expect { builder.perform }.not_to change(User, :count)
|
||||
@@ -85,7 +85,7 @@ RSpec.describe SamlUserBuilder do
|
||||
expect(user).to eq(existing_user)
|
||||
end
|
||||
|
||||
it 'adds existing user to the account if not already added' do
|
||||
it 'keeps the existing user in the account' do
|
||||
user = builder.perform
|
||||
expect(user.accounts).to include(account)
|
||||
end
|
||||
@@ -105,11 +105,38 @@ RSpec.describe SamlUserBuilder do
|
||||
end
|
||||
|
||||
it 'does not duplicate account association' do
|
||||
existing_user.account_users.create!(account: account, role: 'agent')
|
||||
|
||||
expect { builder.perform }.not_to change(AccountUser, :count)
|
||||
end
|
||||
|
||||
context 'when the user does not belong to the target account' do
|
||||
let!(:other_account) { create(:account) }
|
||||
let!(:existing_user) { create(:user, email: email, account: other_account) }
|
||||
|
||||
it 'raises an authentication failure' do
|
||||
expect { builder.perform }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SamlUserBuilder::AuthenticationFailed')
|
||||
expect(error.message).to eq(I18n.t('auth.saml.authentication_failed'))
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not add the user to the target account' do
|
||||
expect do
|
||||
builder.perform
|
||||
rescue SamlUserBuilder::AuthenticationFailed
|
||||
nil
|
||||
end.not_to change(AccountUser, :count)
|
||||
expect(existing_user.reload.accounts).not_to include(account)
|
||||
end
|
||||
|
||||
it 'does not convert the user provider to saml' do
|
||||
expect do
|
||||
builder.perform
|
||||
rescue SamlUserBuilder::AuthenticationFailed
|
||||
nil
|
||||
end.not_to(change { existing_user.reload.provider })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is not confirmed' do
|
||||
let(:unconfirmed_email) { 'unconfirmed_saml_user@example.com' }
|
||||
let(:unconfirmed_auth_hash) do
|
||||
@@ -131,7 +158,7 @@ RSpec.describe SamlUserBuilder do
|
||||
end
|
||||
let(:unconfirmed_builder) { described_class.new(unconfirmed_auth_hash, account.id) }
|
||||
let!(:existing_user) do
|
||||
user = build(:user, email: unconfirmed_email)
|
||||
user = build(:user, email: unconfirmed_email, account: account)
|
||||
user.confirmed_at = nil
|
||||
user.save!(validate: false)
|
||||
user
|
||||
@@ -147,7 +174,7 @@ RSpec.describe SamlUserBuilder do
|
||||
end
|
||||
|
||||
context 'when user is already confirmed' do
|
||||
let!(:existing_user) { create(:user, email: email, confirmed_at: Time.current) }
|
||||
let!(:existing_user) { create(:user, email: email, account: account, confirmed_at: Time.current) }
|
||||
|
||||
it 'keeps already confirmed user confirmed' do
|
||||
expect(existing_user.confirmed?).to be true
|
||||
|
||||
+17
@@ -57,5 +57,22 @@ RSpec.describe 'Enterprise SAML OmniAuth Callbacks', type: :request do
|
||||
expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$})
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects an existing user from another account' do
|
||||
with_modified_env FRONTEND_URL: 'http://www.example.com' do
|
||||
other_account = create(:account)
|
||||
existing_user = create(:user, email: 'existing@example.com', account: other_account, provider: 'email')
|
||||
set_saml_config('existing@example.com')
|
||||
|
||||
get "/omniauth/saml/callback?account_id=#{account.id}"
|
||||
|
||||
expect(response).to redirect_to('http://www.example.com/auth/saml/callback')
|
||||
follow_redirect!
|
||||
|
||||
expect(response).to redirect_to('http://www.example.com/app/login?error=saml-authentication-failed')
|
||||
expect(existing_user.reload.provider).to eq('email')
|
||||
expect(existing_user.accounts).not_to include(account)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,16 +2,14 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
|
||||
let!(:inbox) { create(:inbox) }
|
||||
|
||||
let!(:resolvable_pending_conversation) { create(:conversation, inbox: inbox, last_activity_at: 2.hours.ago, status: :pending) }
|
||||
let!(:recent_pending_conversation) { create(:conversation, inbox: inbox, last_activity_at: 10.minutes.ago, status: :pending) }
|
||||
let!(:recent_pending_conversation) { create(:conversation, inbox: inbox, last_activity_at: 1.minute.ago, status: :pending) }
|
||||
let!(:open_conversation) { create(:conversation, inbox: inbox, last_activity_at: 1.hour.ago, status: :open) }
|
||||
|
||||
let!(:captain_assistant) { create(:captain_assistant, account: inbox.account) }
|
||||
|
||||
before do
|
||||
create(:captain_inbox, inbox: inbox, captain_assistant: captain_assistant)
|
||||
stub_const('Limits::BULK_ACTIONS_LIMIT', 2)
|
||||
stub_const('Limits::BULK_ACTIONS_LIMIT', 3)
|
||||
inbox.reload
|
||||
end
|
||||
|
||||
@@ -20,49 +18,307 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
|
||||
.to have_enqueued_job.on_queue('low')
|
||||
end
|
||||
|
||||
it 'resolves only the eligible pending conversations' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(resolvable_pending_conversation.reload.status).to eq('resolved')
|
||||
expect(recent_pending_conversation.reload.status).to eq('pending')
|
||||
expect(open_conversation.reload.status).to eq('open')
|
||||
end
|
||||
|
||||
it 'creates exactly one outgoing message with configured content' do
|
||||
custom_message = 'This is a custom resolution message.'
|
||||
captain_assistant.update!(config: { 'resolution_message' => custom_message })
|
||||
|
||||
expect do
|
||||
context 'when captain_tasks is disabled' do
|
||||
it 'resolves pending conversations inactive for over 1 hour' do
|
||||
described_class.perform_now(inbox)
|
||||
end.to change { resolvable_pending_conversation.messages.outgoing.reload.count }.by(1)
|
||||
|
||||
outgoing_message = resolvable_pending_conversation.messages.outgoing.last
|
||||
expect(outgoing_message.content).to eq(custom_message)
|
||||
expect(resolvable_pending_conversation.reload.status).to eq('resolved')
|
||||
end
|
||||
|
||||
it 'does not resolve recent pending conversations' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(recent_pending_conversation.reload.status).to eq('pending')
|
||||
end
|
||||
|
||||
it 'does not affect open conversations' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(open_conversation.reload.status).to eq('open')
|
||||
end
|
||||
|
||||
it 'does not call ConversationCompletionService' do
|
||||
allow(Captain::ConversationCompletionService).to receive(:new)
|
||||
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(Captain::ConversationCompletionService).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates an outgoing message with default auto resolution message if not configured' do
|
||||
captain_assistant.update!(config: {})
|
||||
context 'when captain_tasks is enabled' 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(true)
|
||||
end
|
||||
|
||||
described_class.perform_now(inbox)
|
||||
outgoing_message = resolvable_pending_conversation.messages.outgoing.last
|
||||
expect(outgoing_message.content).to eq(
|
||||
I18n.t('conversations.activity.auto_resolution_message')
|
||||
)
|
||||
end
|
||||
it 'only evaluates eligible pending conversations (inactive > 1 hour)' do
|
||||
allow(Captain::ConversationCompletionService).to receive(:new).and_call_original
|
||||
|
||||
it 'adds the correct activity message after resolution by Captain' do
|
||||
described_class.perform_now(inbox)
|
||||
expected_content = I18n.t('conversations.activity.captain.resolved', user_name: captain_assistant.name)
|
||||
expect(Conversations::ActivityMessageJob)
|
||||
.to have_been_enqueued.with(
|
||||
resolvable_pending_conversation,
|
||||
{
|
||||
account_id: resolvable_pending_conversation.account_id,
|
||||
inbox_id: resolvable_pending_conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: expected_content
|
||||
}
|
||||
# Mock the service to return complete for all conversations
|
||||
mock_service = instance_double(Captain::ConversationCompletionService)
|
||||
allow(mock_service).to receive(:perform).and_return({ complete: true, reason: 'Test' })
|
||||
allow(Captain::ConversationCompletionService).to receive(:new).and_return(mock_service)
|
||||
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
# Only resolvable conversation should be evaluated (not recent or open)
|
||||
expect(Captain::ConversationCompletionService).to have_received(:new).with(
|
||||
account: inbox.account,
|
||||
conversation_display_id: resolvable_pending_conversation.display_id
|
||||
)
|
||||
expect(recent_pending_conversation.reload.status).to eq('pending')
|
||||
expect(open_conversation.reload.status).to eq('open')
|
||||
end
|
||||
|
||||
it 'skips auto-action if conversation receives new activity after evaluation' do
|
||||
mock_service = instance_double(Captain::ConversationCompletionService)
|
||||
allow(mock_service).to receive(:perform) do
|
||||
resolvable_pending_conversation.update!(last_activity_at: Time.current)
|
||||
{ complete: true, reason: 'Customer question was answered' }
|
||||
end
|
||||
allow(Captain::ConversationCompletionService).to receive(:new).and_return(mock_service)
|
||||
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(resolvable_pending_conversation.reload.status).to eq('pending')
|
||||
expect(resolvable_pending_conversation.messages.outgoing).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'when LLM evaluation returns complete' 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(true)
|
||||
mock_service = instance_double(Captain::ConversationCompletionService)
|
||||
allow(mock_service).to receive(:perform).and_return({ complete: true, reason: 'Customer question was answered' })
|
||||
allow(Captain::ConversationCompletionService).to receive(:new).and_return(mock_service)
|
||||
end
|
||||
|
||||
it 'resolves the conversation' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(resolvable_pending_conversation.reload.status).to eq('resolved')
|
||||
end
|
||||
|
||||
it 'creates a private note with the reason' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
private_note = resolvable_pending_conversation.messages.where(private: true).last
|
||||
expect(private_note.content).to eq('Auto-resolved: Customer question was answered')
|
||||
end
|
||||
|
||||
it 'creates resolution message with configured content' do
|
||||
custom_message = 'This is a custom resolution message.'
|
||||
captain_assistant.update!(config: { 'resolution_message' => custom_message })
|
||||
inbox.reload
|
||||
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
public_message = resolvable_pending_conversation.messages.where(private: false).outgoing.last
|
||||
expect(public_message.content).to eq(custom_message)
|
||||
end
|
||||
|
||||
it 'creates resolution message with default if not configured' do
|
||||
captain_assistant.update!(config: {})
|
||||
inbox.reload
|
||||
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
public_message = resolvable_pending_conversation.messages.where(private: false).outgoing.last
|
||||
expect(public_message.content).to eq(I18n.t('conversations.activity.auto_resolution_message'))
|
||||
end
|
||||
|
||||
it 'adds the correct activity message after resolution' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expected_content = I18n.t(
|
||||
'conversations.activity.captain.resolved_with_reason',
|
||||
user_name: captain_assistant.name,
|
||||
reason: 'no outstanding questions'
|
||||
)
|
||||
expect(Conversations::ActivityMessageJob)
|
||||
.to have_been_enqueued.with(
|
||||
resolvable_pending_conversation,
|
||||
{
|
||||
account_id: resolvable_pending_conversation.account_id,
|
||||
inbox_id: resolvable_pending_conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: expected_content
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates a captain inference resolved reporting event' do
|
||||
perform_enqueued_jobs do
|
||||
described_class.perform_now(inbox)
|
||||
end
|
||||
|
||||
inference_event = ReportingEvent.find_by(
|
||||
conversation_id: resolvable_pending_conversation.id,
|
||||
name: 'conversation_captain_inference_resolved'
|
||||
)
|
||||
expect(inference_event).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
context 'when LLM evaluation returns incomplete' do
|
||||
let(:handoff_reason) { 'Assistant asked for order number but customer did not respond' }
|
||||
|
||||
before do
|
||||
allow(inbox.account).to receive(:feature_enabled?).and_call_original
|
||||
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
mock_service = instance_double(Captain::ConversationCompletionService)
|
||||
allow(mock_service).to receive(:perform).and_return({ complete: false, reason: handoff_reason })
|
||||
allow(Captain::ConversationCompletionService).to receive(:new).and_return(mock_service)
|
||||
end
|
||||
|
||||
it 'hands off the conversation to agents (status becomes open)' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(resolvable_pending_conversation.reload.status).to eq('open')
|
||||
end
|
||||
|
||||
it 'creates a private note with the reason' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
private_note = resolvable_pending_conversation.messages.where(private: true).last
|
||||
expect(private_note.content).to eq("Auto-handoff: #{handoff_reason}")
|
||||
end
|
||||
|
||||
it 'creates handoff message with configured content' do
|
||||
handoff_message = 'Connecting you to a human agent...'
|
||||
captain_assistant.update!(config: { 'handoff_message' => handoff_message })
|
||||
inbox.reload
|
||||
allow(inbox.account).to receive(:feature_enabled?).and_call_original
|
||||
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
public_message = resolvable_pending_conversation.messages.where(private: false).outgoing.last
|
||||
expect(public_message.content).to eq(handoff_message)
|
||||
expect(public_message.additional_attributes['preserve_waiting_since']).to be_nil
|
||||
end
|
||||
|
||||
it 'preserves existing waiting_since when handoff message is configured' do
|
||||
handoff_message = 'Connecting you to a human agent...'
|
||||
original_waiting_since = 3.hours.ago
|
||||
|
||||
captain_assistant.update!(config: { 'handoff_message' => handoff_message })
|
||||
resolvable_pending_conversation.update!(waiting_since: original_waiting_since)
|
||||
allow(MessageTemplates::Template::OutOfOffice).to receive(:perform_if_applicable)
|
||||
inbox.reload
|
||||
allow(inbox.account).to receive(:feature_enabled?).and_call_original
|
||||
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(resolvable_pending_conversation.reload.waiting_since).to be_within(1.second).of(original_waiting_since)
|
||||
end
|
||||
|
||||
it 'does not create handoff message if not configured' do
|
||||
captain_assistant.update!(config: {})
|
||||
inbox.reload
|
||||
allow(inbox.account).to receive(:feature_enabled?).and_call_original
|
||||
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
|
||||
expect do
|
||||
described_class.perform_now(inbox)
|
||||
end.not_to(change { resolvable_pending_conversation.messages.where(private: false).count })
|
||||
end
|
||||
|
||||
it 'adds the correct activity message after handoff' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expected_content = I18n.t(
|
||||
'conversations.activity.captain.open_with_reason',
|
||||
user_name: captain_assistant.name,
|
||||
reason: 'pending clarification from customer'
|
||||
)
|
||||
expect(Conversations::ActivityMessageJob)
|
||||
.to have_been_enqueued.with(
|
||||
resolvable_pending_conversation,
|
||||
{
|
||||
account_id: resolvable_pending_conversation.account_id,
|
||||
inbox_id: resolvable_pending_conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: expected_content
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates a captain inference handoff reporting event' do
|
||||
perform_enqueued_jobs do
|
||||
described_class.perform_now(inbox)
|
||||
end
|
||||
|
||||
inference_event = ReportingEvent.find_by(
|
||||
conversation_id: resolvable_pending_conversation.id,
|
||||
name: 'conversation_captain_inference_handoff'
|
||||
)
|
||||
expect(inference_event).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handoff occurs outside business hours' do
|
||||
let(:handoff_reason) { 'Customer has not responded to clarifying question' }
|
||||
|
||||
before do
|
||||
allow(inbox.account).to receive(:feature_enabled?).and_call_original
|
||||
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
mock_service = instance_double(Captain::ConversationCompletionService)
|
||||
allow(mock_service).to receive(:perform).and_return({ complete: false, reason: handoff_reason })
|
||||
allow(Captain::ConversationCompletionService).to receive(:new).and_return(mock_service)
|
||||
inbox.update!(working_hours_enabled: true, out_of_office_message: 'We are currently unavailable.')
|
||||
end
|
||||
|
||||
it 'sends OOO message for non-campaign conversations' do
|
||||
travel_to '01.11.2020 13:00'.to_datetime do
|
||||
resolvable_pending_conversation.update!(last_activity_at: 2.hours.ago)
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
ooo_message = resolvable_pending_conversation.messages.template.last
|
||||
expect(ooo_message).to be_present
|
||||
expect(ooo_message.content).to eq('We are currently unavailable.')
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not send OOO message for campaign conversations' do
|
||||
campaign = create(:campaign, account: inbox.account, inbox: inbox)
|
||||
resolvable_pending_conversation.update!(campaign: campaign)
|
||||
|
||||
travel_to '01.11.2020 13:00'.to_datetime do
|
||||
resolvable_pending_conversation.update!(last_activity_at: 2.hours.ago)
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(resolvable_pending_conversation.messages.template).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not send OOO message during business hours' do
|
||||
travel_to '26.10.2020 10:00'.to_datetime do
|
||||
resolvable_pending_conversation.update!(last_activity_at: 2.hours.ago)
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(resolvable_pending_conversation.messages.template).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when LLM evaluation fails' 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(true)
|
||||
mock_service = instance_double(Captain::ConversationCompletionService)
|
||||
allow(mock_service).to receive(:perform).and_return({ complete: false, reason: 'API Error' })
|
||||
allow(Captain::ConversationCompletionService).to receive(:new).and_return(mock_service)
|
||||
end
|
||||
|
||||
it 'hands off as safe default' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(resolvable_pending_conversation.reload.status).to eq('open')
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not resolve conversations when auto-resolve is disabled at execution time' do
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::ConversationCompletionService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:service) { described_class.new(account: account, conversation_display_id: conversation.display_id) }
|
||||
let(:mock_chat) { instance_double(RubyLLM::Chat) }
|
||||
let(:mock_context) { instance_double(RubyLLM::Context, chat: mock_chat) }
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
|
||||
allow(mock_chat).to receive(:with_instructions)
|
||||
allow(mock_chat).to receive(:with_schema).and_return(mock_chat)
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when conversation is complete' do
|
||||
let(:mock_response) do
|
||||
instance_double(
|
||||
RubyLLM::Message,
|
||||
content: { 'complete' => true, 'reason' => 'Customer question was fully answered' },
|
||||
input_tokens: 100,
|
||||
output_tokens: 20
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'What are your hours?')
|
||||
create(:message, conversation: conversation, message_type: :outgoing, content: 'We are open 9-5 Monday to Friday.')
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'Thanks!')
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
end
|
||||
|
||||
it 'returns complete: true with reason' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:complete]).to be true
|
||||
expect(result[:reason]).to eq('Customer question was fully answered')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation is incomplete' do
|
||||
let(:mock_response) do
|
||||
instance_double(
|
||||
RubyLLM::Message,
|
||||
content: { 'complete' => false, 'reason' => 'Assistant asked for order number but customer did not respond' },
|
||||
input_tokens: 100,
|
||||
output_tokens: 20
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'Where is my order?')
|
||||
create(:message, conversation: conversation, message_type: :outgoing, content: 'Can you please share your order number?')
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
end
|
||||
|
||||
it 'returns complete: false with reason' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:complete]).to be false
|
||||
expect(result[:reason]).to eq('Assistant asked for order number but customer did not respond')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation has no messages' do
|
||||
it 'returns incomplete with appropriate reason' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:complete]).to be false
|
||||
expect(result[:reason]).to eq('No messages found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when LLM returns non-hash response' do
|
||||
let(:mock_response) do
|
||||
instance_double(
|
||||
RubyLLM::Message,
|
||||
content: 'unexpected string response',
|
||||
input_tokens: 100,
|
||||
output_tokens: 20
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'Hello')
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
end
|
||||
|
||||
it 'returns incomplete as safe default' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:complete]).to be false
|
||||
expect(result[:reason]).to eq('Invalid response format')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API call fails' do
|
||||
before do
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'Hello')
|
||||
allow(mock_chat).to receive(:ask).and_raise(StandardError.new('API Error'))
|
||||
end
|
||||
|
||||
it 'returns incomplete with error message' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:complete]).to be false
|
||||
expect(result[:reason]).to eq('API Error')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when captain_tasks feature is disabled' do
|
||||
before do
|
||||
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(false)
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'Hello')
|
||||
end
|
||||
|
||||
it 'does not evaluate the conversation as complete' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:complete]).not_to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account has its own OpenAI hook' do
|
||||
before do
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'Hello')
|
||||
create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'customer-own-key' })
|
||||
end
|
||||
|
||||
it 'uses the system API key instead of the account hook key' do
|
||||
expect(Llm::Config).to receive(:with_api_key).with('test-key', api_base: anything).and_yield(mock_context)
|
||||
allow(mock_chat).to receive(:ask).and_return(
|
||||
instance_double(RubyLLM::Message, content: { 'complete' => true, 'reason' => 'Done' }, input_tokens: 10, output_tokens: 5)
|
||||
)
|
||||
|
||||
service.perform
|
||||
end
|
||||
|
||||
it 'falls back to the account hook key when no system key exists' do
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY').update!(value: nil)
|
||||
|
||||
expect(Llm::Config).to receive(:with_api_key).with('customer-own-key', api_base: anything).and_yield(mock_context)
|
||||
allow(mock_chat).to receive(:ask).and_return(
|
||||
instance_double(RubyLLM::Message, content: { 'complete' => true, 'reason' => 'Done' }, input_tokens: 10, output_tokens: 5)
|
||||
)
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when customer quota is exhausted' do
|
||||
let(:mock_response) do
|
||||
instance_double(
|
||||
RubyLLM::Message,
|
||||
content: { 'complete' => true, 'reason' => 'Customer question was fully answered' },
|
||||
input_tokens: 100,
|
||||
output_tokens: 20
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
||||
allow(account).to receive(:usage_limits).and_return({
|
||||
captain: { responses: { current_available: 0 } }
|
||||
})
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'What are your hours?')
|
||||
create(:message, conversation: conversation, message_type: :outgoing, content: 'We are open 9-5 Monday to Friday.')
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
end
|
||||
|
||||
it 'still runs the evaluation bypassing quota check' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:error]).to be_nil
|
||||
expect(result[:complete]).to be true
|
||||
expect(result[:reason]).to eq('Customer question was fully answered')
|
||||
end
|
||||
|
||||
it 'does not increment usage' do
|
||||
expect(account).not_to receive(:increment_response_usage)
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -63,6 +63,20 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do
|
||||
tool.perform(tool_context, reason: 'Test reason')
|
||||
end
|
||||
|
||||
it 'creates a conversation_bot_handoff reporting event' do
|
||||
create(:captain_inbox, captain_assistant: assistant, inbox: inbox)
|
||||
Current.executed_by = assistant
|
||||
|
||||
perform_enqueued_jobs do
|
||||
tool.perform(tool_context, reason: 'Customer needs specialized support')
|
||||
end
|
||||
|
||||
reporting_event = ReportingEvent.find_by(conversation_id: conversation.id, name: 'conversation_bot_handoff')
|
||||
expect(reporting_event).to be_present
|
||||
ensure
|
||||
Current.reset
|
||||
end
|
||||
|
||||
it 'logs tool usage with reason' do
|
||||
reason = 'Customer needs help'
|
||||
expect(tool).to receive(:log_tool_usage).with(
|
||||
|
||||
@@ -29,10 +29,14 @@ RSpec.describe Captain::Tools::ResolveConversationTool do
|
||||
)
|
||||
end
|
||||
|
||||
it 'clears captain_resolve_reason after execution' do
|
||||
tool.perform(tool_context, reason: 'Possible spam')
|
||||
it 'creates a conversation_resolved reporting event' do
|
||||
create(:captain_inbox, captain_assistant: assistant, inbox: inbox)
|
||||
|
||||
expect(Current.captain_resolve_reason).to be_nil
|
||||
expect do
|
||||
perform_enqueued_jobs do
|
||||
tool.perform(tool_context, reason: 'Possible spam')
|
||||
end
|
||||
end.to change { ReportingEvent.where(conversation_id: conversation.id, name: 'conversation_resolved').count }.by(1)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -8,8 +8,7 @@ describe CaptainListener do
|
||||
let(:assistant) { create(:captain_assistant, account: account, config: { feature_memory: true, feature_faq: true }) }
|
||||
|
||||
describe '#conversation_resolved' do
|
||||
let(:agent) { create(:user, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: agent) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
|
||||
|
||||
let(:event_name) { :conversation_resolved }
|
||||
let(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) }
|
||||
|
||||
@@ -27,12 +27,24 @@ describe ReportingEventListener do
|
||||
end
|
||||
|
||||
it 'creates conversation_resolved event with business hour value' do
|
||||
event = Events::Base.new('conversation.resolved', Time.zone.now, conversation: new_conversation)
|
||||
event = Events::Base.new('conversation.resolved', updated_at, conversation: new_conversation)
|
||||
listener.conversation_resolved(event)
|
||||
expect(account.reporting_events.where(name: 'conversation_resolved')[0]['value_in_business_hours']).to be 144_000.0
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses event timestamp even when conversation updated_at changes later' do
|
||||
resolved_at = conversation.created_at + 20.minutes
|
||||
allow(conversation).to receive(:updated_at).and_return(resolved_at + 10.minutes)
|
||||
event = Events::Base.new('conversation.resolved', resolved_at, conversation: conversation)
|
||||
|
||||
listener.conversation_resolved(event)
|
||||
|
||||
reporting_event = account.reporting_events.where(name: 'conversation_resolved').first
|
||||
expect(reporting_event.value).to eq 1200
|
||||
expect(reporting_event.event_end_time).to be_within(1.second).of(resolved_at)
|
||||
end
|
||||
|
||||
describe 'conversation_bot_resolved' do
|
||||
# create an agent bot
|
||||
let!(:agent_bot_inbox) { create(:inbox, account: account) }
|
||||
@@ -261,11 +273,55 @@ describe ReportingEventListener do
|
||||
end
|
||||
|
||||
it 'creates conversation_bot_handoff event with business hour value' do
|
||||
event = Events::Base.new('conversation.bot_handoff', Time.zone.now, conversation: new_conversation)
|
||||
event = Events::Base.new('conversation.bot_handoff', updated_at, conversation: new_conversation)
|
||||
listener.conversation_bot_handoff(event)
|
||||
expect(account.reporting_events.where(name: 'conversation_bot_handoff')[0]['value_in_business_hours']).to be 144_000.0
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses event timestamp even when conversation updated_at changes later' do
|
||||
handoff_at = conversation.created_at + 10.minutes
|
||||
allow(conversation).to receive(:updated_at).and_return(handoff_at + 15.minutes)
|
||||
event = Events::Base.new('conversation.bot_handoff', handoff_at, conversation: conversation)
|
||||
|
||||
listener.conversation_bot_handoff(event)
|
||||
|
||||
reporting_event = account.reporting_events.where(name: 'conversation_bot_handoff').first
|
||||
expect(reporting_event.value).to eq 600
|
||||
expect(reporting_event.event_end_time).to be_within(1.second).of(handoff_at)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#conversation_captain_inference_resolved' do
|
||||
it 'creates conversation_captain_inference_resolved event' do
|
||||
expect(account.reporting_events.where(name: 'conversation_captain_inference_resolved').count).to be 0
|
||||
decision_time = conversation.created_at + 60.seconds
|
||||
event = Events::Base.new('conversation.captain_inference_resolved', decision_time, conversation: conversation)
|
||||
allow(conversation).to receive(:updated_at).and_return(decision_time + 5.minutes)
|
||||
|
||||
listener.conversation_captain_inference_resolved(event)
|
||||
|
||||
reporting_event = account.reporting_events.where(name: 'conversation_captain_inference_resolved').first
|
||||
expect(reporting_event).to be_present
|
||||
expect(reporting_event.value).to eq 60
|
||||
expect(reporting_event.event_end_time).to be_within(1.second).of(decision_time)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#conversation_captain_inference_handoff' do
|
||||
it 'creates conversation_captain_inference_handoff event' do
|
||||
expect(account.reporting_events.where(name: 'conversation_captain_inference_handoff').count).to be 0
|
||||
decision_time = conversation.created_at + 90.seconds
|
||||
event = Events::Base.new('conversation.captain_inference_handoff', decision_time, conversation: conversation)
|
||||
allow(conversation).to receive(:updated_at).and_return(decision_time + 5.minutes)
|
||||
|
||||
listener.conversation_captain_inference_handoff(event)
|
||||
|
||||
reporting_event = account.reporting_events.where(name: 'conversation_captain_inference_handoff').first
|
||||
expect(reporting_event).to be_present
|
||||
expect(reporting_event.value).to eq 90
|
||||
expect(reporting_event.event_end_time).to be_within(1.second).of(decision_time)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#conversation_opened' do
|
||||
@@ -274,7 +330,8 @@ describe ReportingEventListener do
|
||||
|
||||
it 'creates conversation_opened event with value 0' do
|
||||
expect(account.reporting_events.where(name: 'conversation_opened').count).to be 0
|
||||
event = Events::Base.new('conversation.opened', Time.zone.now, conversation: new_conversation)
|
||||
opened_at = Time.zone.now
|
||||
event = Events::Base.new('conversation.opened', opened_at, conversation: new_conversation)
|
||||
listener.conversation_opened(event)
|
||||
expect(account.reporting_events.where(name: 'conversation_opened').count).to be 1
|
||||
|
||||
@@ -282,7 +339,7 @@ describe ReportingEventListener do
|
||||
expect(opened_event.value).to eq 0
|
||||
expect(opened_event.value_in_business_hours).to eq 0
|
||||
expect(opened_event.event_start_time).to be_within(1.second).of(new_conversation.created_at)
|
||||
expect(opened_event.event_end_time).to be_within(1.second).of(new_conversation.updated_at)
|
||||
expect(opened_event.event_end_time).to be_within(1.second).of(opened_at)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -334,6 +391,17 @@ describe ReportingEventListener do
|
||||
expect(reopened_event.user_id).to eq(user.id)
|
||||
end
|
||||
|
||||
it 'uses event timestamp even when conversation updated_at changes later' do
|
||||
allow(reopened_conversation).to receive(:updated_at).and_return(reopened_time + 20.minutes)
|
||||
event = Events::Base.new('conversation.opened', reopened_time, conversation: reopened_conversation)
|
||||
|
||||
listener.conversation_opened(event)
|
||||
|
||||
reopened_event = account.reporting_events.where(name: 'conversation_opened').first
|
||||
expect(reopened_event.value).to be_within(1).of(3600)
|
||||
expect(reopened_event.event_end_time).to be_within(1.second).of(reopened_time)
|
||||
end
|
||||
|
||||
context 'when business hours enabled for inbox' do
|
||||
let(:resolved_time) { Time.zone.parse('March 20, 2022 12:00') }
|
||||
let(:reopened_time) { Time.zone.parse('March 21, 2022 14:00') }
|
||||
@@ -406,6 +474,45 @@ describe ReportingEventListener do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when latest resolved event is after the conversation opened event timestamp' do
|
||||
let(:previous_resolved_time) { Time.zone.parse('March 22, 2022 09:00') }
|
||||
let(:future_resolved_time) { Time.zone.parse('March 22, 2022 11:00') }
|
||||
let(:reopened_time) { Time.zone.parse('March 22, 2022 10:00') }
|
||||
let(:reopened_conversation) do
|
||||
create(:conversation, account: account, inbox: inbox, assignee: user, updated_at: reopened_time)
|
||||
end
|
||||
|
||||
before do
|
||||
create(:reporting_event,
|
||||
name: 'conversation_resolved',
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
conversation_id: reopened_conversation.id,
|
||||
user_id: user.id,
|
||||
event_start_time: reopened_conversation.created_at,
|
||||
event_end_time: previous_resolved_time)
|
||||
|
||||
create(:reporting_event,
|
||||
name: 'conversation_resolved',
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
conversation_id: reopened_conversation.id,
|
||||
user_id: user.id,
|
||||
event_start_time: previous_resolved_time,
|
||||
event_end_time: future_resolved_time)
|
||||
end
|
||||
|
||||
it 'ignores future resolved events when computing reopen duration' do
|
||||
event = Events::Base.new('conversation.opened', reopened_time, conversation: reopened_conversation)
|
||||
listener.conversation_opened(event)
|
||||
|
||||
reopened_event = account.reporting_events.where(name: 'conversation_opened').first
|
||||
expect(reopened_event.value).to be_within(1).of(3600)
|
||||
expect(reopened_event.event_start_time).to be_within(1.second).of(previous_resolved_time)
|
||||
expect(reopened_event.event_end_time).to be_within(1.second).of(reopened_time)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent bot resolves and conversation is reopened' do
|
||||
# This implicitly tests that the first_response time is correctly calculated
|
||||
# By checking that a conversation reopened event is created with the correct values
|
||||
|
||||
@@ -366,6 +366,25 @@ RSpec.describe Message do
|
||||
expect(conversation.waiting_since).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when bot response should preserve waiting_since' do
|
||||
let(:agent_bot) { create(:agent_bot, account: conversation.account) }
|
||||
|
||||
it 'does not clear waiting_since when preserve_waiting_since is set' do
|
||||
original_waiting_since = 45.minutes.ago
|
||||
conversation.update!(waiting_since: original_waiting_since)
|
||||
|
||||
create(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
message_type: :outgoing,
|
||||
sender: agent_bot,
|
||||
preserve_waiting_since: true
|
||||
)
|
||||
|
||||
expect(conversation.reload.waiting_since).to be_within(1.second).of(original_waiting_since)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with webhook_data' do
|
||||
|
||||
Reference in New Issue
Block a user