feat(captain): add inactivity timer backend (2/5) (#15303)
Captain V2 assistants can now persist a configurable inactivity timer and choose whether inactivity resolution sends the saved closing message or resolves silently. This PR contains only the API, persistence, runtime behavior, and backend specs. ## Closes [AI-163](https://linear.app/chatwoot/issue/AI-163) ## Depends on Stack 2 of 5. Based on the assistant-policy foundation in #15299. The frontend follows in #15308. ## What changed - Added per-assistant inactivity duration and resolution-message settings with safe defaults. - Restricted the Part 2 settings API to Captain V2 while keeping the Part 1 policy mode available without V2. - Updated inactivity handling to use the assistant timer and skip the public resolution message when disabled. - Serialized the effective timer and message settings for the frontend. - Added model, request, and job coverage, including the explicit Captain V2 boundary. ## How to test 1. Enable Captain V2 and update `auto_resolve_after` and `send_inactivity_resolution_message` through the assistant API. 2. Run the inactivity job and confirm it uses the assistant timer. 3. Disable the resolution message and confirm the conversation resolves silently. 4. Disable Captain V2 and confirm timer/message updates are ignored while `auto_resolve_mode` remains updateable. --------- Co-authored-by: Sony Mathew <sony@chatwoot.com> Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin <iamsivin@gmail.com>
This commit is contained in:
@@ -42,6 +42,7 @@ const modelValue = defineModel({ type: Boolean, default: false });
|
||||
<span v-if="description" class="text-body-main text-n-slate-11">
|
||||
{{ description }}
|
||||
</span>
|
||||
<slot />
|
||||
</div>
|
||||
<div
|
||||
v-if="$slots.editor"
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
import { reactive, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { minLength } from '@vuelidate/validators';
|
||||
import { maxValue, minLength, minValue, required } from '@vuelidate/validators';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import Select from 'dashboard/components-next/select/Select.vue';
|
||||
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
|
||||
import Switch from 'dashboard/components-next/switch/Switch.vue';
|
||||
|
||||
const props = defineProps({
|
||||
assistant: {
|
||||
@@ -29,14 +32,87 @@ const initialState = {
|
||||
handoffMessage: '',
|
||||
resolutionMessage: '',
|
||||
instructions: '',
|
||||
inactivityThresholdMinutes: 60,
|
||||
sendInactivityResolutionMessage: true,
|
||||
};
|
||||
|
||||
const state = reactive({ ...initialState });
|
||||
|
||||
const MINUTES_PER_HOUR = 60;
|
||||
const INACTIVITY_STEP_MINUTES = 5;
|
||||
const MIN_INACTIVITY_MINUTES = 5;
|
||||
const MAX_INACTIVITY_MINUTES = 24 * MINUTES_PER_HOUR;
|
||||
const MAX_INACTIVITY_HOURS = MAX_INACTIVITY_MINUTES / MINUTES_PER_HOUR;
|
||||
|
||||
const hoursPart = totalMinutes => Math.floor(totalMinutes / MINUTES_PER_HOUR);
|
||||
const minutesPart = totalMinutes => totalMinutes % MINUTES_PER_HOUR;
|
||||
|
||||
const setInactivityThreshold = (hours, minutes) => {
|
||||
state.inactivityThresholdMinutes = Math.min(
|
||||
Math.max(hours * MINUTES_PER_HOUR + minutes, MIN_INACTIVITY_MINUTES),
|
||||
MAX_INACTIVITY_MINUTES
|
||||
);
|
||||
};
|
||||
|
||||
const inactivityThresholdHours = computed({
|
||||
get: () => hoursPart(state.inactivityThresholdMinutes),
|
||||
set: hours =>
|
||||
setInactivityThreshold(
|
||||
Number(hours),
|
||||
minutesPart(state.inactivityThresholdMinutes)
|
||||
),
|
||||
});
|
||||
|
||||
const inactivityThresholdRemainingMinutes = computed({
|
||||
get: () => minutesPart(state.inactivityThresholdMinutes),
|
||||
set: minutes =>
|
||||
setInactivityThreshold(
|
||||
hoursPart(state.inactivityThresholdMinutes),
|
||||
Number(minutes)
|
||||
),
|
||||
});
|
||||
|
||||
const inactivityHourOptions = computed(() =>
|
||||
Array.from({ length: MAX_INACTIVITY_HOURS + 1 }, (_, hours) => ({
|
||||
value: hours,
|
||||
label: t(
|
||||
'CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.DURATION_HOURS_SHORT',
|
||||
{ count: hours }
|
||||
),
|
||||
}))
|
||||
);
|
||||
|
||||
const inactivityMinuteOptions = computed(() => {
|
||||
const hours = inactivityThresholdHours.value;
|
||||
|
||||
return Array.from(
|
||||
{ length: MINUTES_PER_HOUR / INACTIVITY_STEP_MINUTES },
|
||||
(_, index) => {
|
||||
const minutes = index * INACTIVITY_STEP_MINUTES;
|
||||
|
||||
return {
|
||||
value: minutes,
|
||||
label: t(
|
||||
'CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.DURATION_MINUTES_SHORT',
|
||||
{ count: minutes }
|
||||
),
|
||||
disabled:
|
||||
(hours === 0 && minutes === 0) ||
|
||||
(hours === MAX_INACTIVITY_HOURS && minutes !== 0),
|
||||
};
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const validationRules = {
|
||||
handoffMessage: { minLength: minLength(1) },
|
||||
resolutionMessage: { minLength: minLength(1) },
|
||||
instructions: { minLength: minLength(1) },
|
||||
inactivityThresholdMinutes: {
|
||||
required,
|
||||
minValue: minValue(MIN_INACTIVITY_MINUTES),
|
||||
maxValue: maxValue(MAX_INACTIVITY_MINUTES),
|
||||
},
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(validationRules, state);
|
||||
@@ -49,6 +125,7 @@ const formErrors = computed(() => ({
|
||||
handoffMessage: getErrorMessage('handoffMessage'),
|
||||
resolutionMessage: getErrorMessage('resolutionMessage'),
|
||||
instructions: getErrorMessage('instructions'),
|
||||
inactivityThresholdMinutes: getErrorMessage('inactivityThresholdMinutes'),
|
||||
}));
|
||||
|
||||
const updateStateFromAssistant = assistant => {
|
||||
@@ -56,16 +133,24 @@ const updateStateFromAssistant = assistant => {
|
||||
state.handoffMessage = config.handoff_message;
|
||||
state.resolutionMessage = config.resolution_message;
|
||||
state.instructions = config.instructions;
|
||||
state.inactivityThresholdMinutes = config.auto_resolve_after ?? 60;
|
||||
state.sendInactivityResolutionMessage =
|
||||
config.send_inactivity_resolution_message ?? true;
|
||||
};
|
||||
|
||||
const handleSystemMessagesUpdate = async () => {
|
||||
const validations = [
|
||||
v$.value.handoffMessage.$validate(),
|
||||
v$.value.resolutionMessage.$validate(),
|
||||
];
|
||||
const validations = [v$.value.handoffMessage.$validate()];
|
||||
|
||||
if (!isCaptainV2Enabled.value) {
|
||||
validations.push(v$.value.instructions.$validate());
|
||||
if (isCaptainV2Enabled.value) {
|
||||
validations.push(v$.value.inactivityThresholdMinutes.$validate());
|
||||
if (state.sendInactivityResolutionMessage) {
|
||||
validations.push(v$.value.resolutionMessage.$validate());
|
||||
}
|
||||
} else {
|
||||
validations.push(
|
||||
v$.value.resolutionMessage.$validate(),
|
||||
v$.value.instructions.$validate()
|
||||
);
|
||||
}
|
||||
|
||||
const result = await Promise.all(validations).then(results =>
|
||||
@@ -77,11 +162,17 @@ const handleSystemMessagesUpdate = async () => {
|
||||
config: {
|
||||
...props.assistant.config,
|
||||
handoff_message: state.handoffMessage,
|
||||
resolution_message: state.resolutionMessage,
|
||||
},
|
||||
};
|
||||
|
||||
if (!isCaptainV2Enabled.value) {
|
||||
if (isCaptainV2Enabled.value) {
|
||||
Object.assign(payload.config, {
|
||||
auto_resolve_after: state.inactivityThresholdMinutes,
|
||||
send_inactivity_resolution_message: state.sendInactivityResolutionMessage,
|
||||
resolution_message: state.resolutionMessage,
|
||||
});
|
||||
} else {
|
||||
payload.config.resolution_message = state.resolutionMessage;
|
||||
payload.config.instructions = state.instructions;
|
||||
}
|
||||
|
||||
@@ -99,16 +190,103 @@ watch(
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Editor
|
||||
v-model="state.handoffMessage"
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.LABEL')"
|
||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')"
|
||||
:message="formErrors.handoffMessage"
|
||||
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
|
||||
class="z-0"
|
||||
/>
|
||||
<SettingsToggleSection
|
||||
v-if="isCaptainV2Enabled"
|
||||
hide-toggle
|
||||
:header="t('CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.TITLE')"
|
||||
>
|
||||
<div class="flex w-full flex-col gap-4 py-2">
|
||||
<div
|
||||
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<span class="text-body-main text-n-slate-12">
|
||||
{{
|
||||
t('CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.DURATION_LABEL')
|
||||
}}
|
||||
</span>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<Select
|
||||
v-model="inactivityThresholdHours"
|
||||
:options="inactivityHourOptions"
|
||||
:error="formErrors.inactivityThresholdMinutes"
|
||||
:aria-label="
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.DURATION_HOURS_ARIA_LABEL'
|
||||
)
|
||||
"
|
||||
class="[&>select]:min-w-24"
|
||||
/>
|
||||
<Select
|
||||
v-model="inactivityThresholdRemainingMinutes"
|
||||
:options="inactivityMinuteOptions"
|
||||
:error="formErrors.inactivityThresholdMinutes"
|
||||
:aria-label="
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.DURATION_MINUTES_ARIA_LABEL'
|
||||
)
|
||||
"
|
||||
class="[&>select]:min-w-28"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="formErrors.inactivityThresholdMinutes"
|
||||
class="mb-0 text-xs text-n-ruby-9"
|
||||
>
|
||||
{{ formErrors.inactivityThresholdMinutes }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-body-main text-n-slate-12">
|
||||
{{
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.RESOLUTION_MESSAGE.TITLE'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<Switch
|
||||
v-model="state.sendInactivityResolutionMessage"
|
||||
:aria-label="
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.RESOLUTION_MESSAGE.TITLE'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="state.sendInactivityResolutionMessage" #editor>
|
||||
<Editor
|
||||
v-model="state.resolutionMessage"
|
||||
:placeholder="
|
||||
t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')
|
||||
"
|
||||
:message="formErrors.resolutionMessage"
|
||||
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
|
||||
class="z-0 [&_.editor-wrapper]:!min-h-32 [&_.editor-wrapper]:!border-0 [&_.editor-wrapper]:!bg-transparent [&_.editor-wrapper]:!p-0"
|
||||
/>
|
||||
</template>
|
||||
</SettingsToggleSection>
|
||||
|
||||
<SettingsToggleSection
|
||||
hide-toggle
|
||||
:header="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.LABEL')"
|
||||
>
|
||||
<template #editor>
|
||||
<Editor
|
||||
v-model="state.handoffMessage"
|
||||
:placeholder="
|
||||
t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')
|
||||
"
|
||||
:message="formErrors.handoffMessage"
|
||||
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
|
||||
class="z-0 [&_.editor-wrapper]:!min-h-32 [&_.editor-wrapper]:!border-0 [&_.editor-wrapper]:!bg-transparent [&_.editor-wrapper]:!p-0"
|
||||
/>
|
||||
</template>
|
||||
</SettingsToggleSection>
|
||||
|
||||
<Editor
|
||||
v-if="!isCaptainV2Enabled"
|
||||
v-model="state.resolutionMessage"
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.LABEL')"
|
||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')"
|
||||
|
||||
@@ -33,6 +33,10 @@ defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
ariaLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const modelValue = defineModel({
|
||||
@@ -46,6 +50,7 @@ const modelValue = defineModel({
|
||||
<select
|
||||
v-model="modelValue"
|
||||
:disabled="disabled"
|
||||
:aria-label="ariaLabel || undefined"
|
||||
class="appearance-none bg-none rounded-lg border-0 outline-1 outline -outline-offset-1 transition-all duration-200 bg-n-surface-1 !mb-0 py-2 px-3 pr-10 text-sm"
|
||||
:class="{
|
||||
'outline-n-weak hover:outline-n-slate-6 focus:outline-n-blue-9':
|
||||
|
||||
@@ -591,13 +591,24 @@
|
||||
"PLACEHOLDER": "Enter welcome message"
|
||||
},
|
||||
"HANDOFF_MESSAGE": {
|
||||
"LABEL": "Handoff Message",
|
||||
"LABEL": "Handoff message",
|
||||
"PLACEHOLDER": "Enter handoff message"
|
||||
},
|
||||
"RESOLUTION_MESSAGE": {
|
||||
"LABEL": "Resolution Message",
|
||||
"PLACEHOLDER": "Enter resolution message"
|
||||
},
|
||||
"INACTIVITY_RESOLUTION": {
|
||||
"TITLE": "Captain behavior on inactive conversations",
|
||||
"RESOLUTION_MESSAGE": {
|
||||
"TITLE": "Resolution message"
|
||||
},
|
||||
"DURATION_LABEL": "Inactivity period before Captain acts",
|
||||
"DURATION_HOURS_ARIA_LABEL": "Inactivity period hours",
|
||||
"DURATION_MINUTES_ARIA_LABEL": "Inactivity period minutes",
|
||||
"DURATION_HOURS_SHORT": "{count} h",
|
||||
"DURATION_MINUTES_SHORT": "{count} min"
|
||||
},
|
||||
"INSTRUCTIONS": {
|
||||
"LABEL": "Instructions",
|
||||
"PLACEHOLDER": "Enter instructions for the assistant"
|
||||
@@ -666,7 +677,8 @@
|
||||
},
|
||||
"SYSTEM_SETTINGS": {
|
||||
"TITLE": "System settings",
|
||||
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
|
||||
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human.",
|
||||
"DESCRIPTION_V2": "Manage inactive conversations and Captain's handoff message."
|
||||
},
|
||||
"AUDIENCE": {
|
||||
"TITLE": "Audience",
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useAssistantSettings } from './useAssistantSettings';
|
||||
import SettingsPageLayout from 'dashboard/components-next/captain/pageComponents/assistant/settings/SettingsPageLayout.vue';
|
||||
import AssistantSystemSettingsForm from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
const { assistant, updateAssistant } = useAssistantSettings();
|
||||
|
||||
const systemSettingsDescription = computed(() =>
|
||||
isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_V2)
|
||||
? t('CAPTAIN.ASSISTANTS.SETTINGS.SYSTEM_SETTINGS.DESCRIPTION_V2')
|
||||
: t('CAPTAIN.ASSISTANTS.SETTINGS.SYSTEM_SETTINGS.DESCRIPTION')
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsPageLayout
|
||||
:heading="t('CAPTAIN.ASSISTANTS.SETTINGS.SYSTEM_SETTINGS.TITLE')"
|
||||
:description="t('CAPTAIN.ASSISTANTS.SETTINGS.SYSTEM_SETTINGS.DESCRIPTION')"
|
||||
:description="systemSettingsDescription"
|
||||
>
|
||||
<AssistantSystemSettingsForm
|
||||
:assistant="assistant"
|
||||
|
||||
@@ -130,6 +130,9 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
:resolution_message, :instructions, :temperature, :auto_resolve_mode,
|
||||
:response_window
|
||||
]
|
||||
if Current.account.feature_enabled?('captain_integration_v2')
|
||||
assistant_config_attributes += [:auto_resolve_after, :send_inactivity_resolution_message]
|
||||
end
|
||||
|
||||
permitted = params.require(:assistant).permit(:name, :description,
|
||||
config: assistant_config_attributes)
|
||||
|
||||
@@ -8,6 +8,7 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
|
||||
captain_assistant = inbox.captain_assistant
|
||||
return if captain_assistant.blank? || captain_assistant.inactive_conversation_resolution_disabled?
|
||||
|
||||
@inactivity_cutoff_time = Time.now.utc - captain_assistant.inactivity_threshold_minutes.minutes
|
||||
if evaluate_conversation_completion?(captain_assistant, inbox.account)
|
||||
perform_with_evaluation(inbox)
|
||||
else
|
||||
@@ -19,6 +20,8 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
|
||||
|
||||
private
|
||||
|
||||
attr_reader :inactivity_cutoff_time
|
||||
|
||||
def evaluate_conversation_completion?(assistant, account)
|
||||
account.feature_enabled?('captain_tasks') && assistant.evaluate_inactive_conversations_before_resolving?
|
||||
end
|
||||
@@ -62,21 +65,17 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
|
||||
|
||||
def resolvable_pending_conversations(inbox)
|
||||
inbox.conversations.pending
|
||||
.where('last_activity_at < ?', auto_resolve_cutoff_time)
|
||||
.where('last_activity_at < ?', inactivity_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
|
||||
conversation.pending? && conversation.last_activity_at < inactivity_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)
|
||||
@@ -129,6 +128,8 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
|
||||
end
|
||||
|
||||
def create_resolution_message(conversation, inbox)
|
||||
return unless inbox.captain_assistant.send_inactivity_resolution_message?
|
||||
|
||||
I18n.with_locale(inbox.account.locale) do
|
||||
resolution_message = inbox.captain_assistant.config['resolution_message']
|
||||
conversation.messages.create!(
|
||||
|
||||
@@ -20,6 +20,10 @@ class Captain::Assistant < ApplicationRecord
|
||||
DESCRIPTION_LENGTH_LIMIT = 500
|
||||
CITATION_SOURCES_STATE_KEY = :captain_v2_citation_sources
|
||||
AUTO_RESOLVE_MODES = %w[disabled legacy evaluated].freeze
|
||||
DEFAULT_INACTIVITY_THRESHOLD_MINUTES = 60
|
||||
MINIMUM_INACTIVITY_THRESHOLD_MINUTES = 5
|
||||
MAXIMUM_INACTIVITY_THRESHOLD_MINUTES = 1.day.in_minutes.to_i
|
||||
INACTIVITY_THRESHOLD_STEP_MINUTES = 5
|
||||
RESPONSE_WINDOWS = %w[always business_hours outside_business_hours].freeze
|
||||
|
||||
include Avatarable
|
||||
@@ -45,9 +49,10 @@ class Captain::Assistant < ApplicationRecord
|
||||
has_many :conversation_outcomes, dependent: :destroy_async
|
||||
|
||||
store_accessor :config, :temperature, :feature_faq, :feature_memory, :feature_contact_attributes, :product_name,
|
||||
:auto_resolve_mode, :response_window
|
||||
:auto_resolve_mode, :auto_resolve_after, :send_inactivity_resolution_message, :response_window
|
||||
|
||||
before_validation :set_default_auto_resolve_mode, on: :create
|
||||
before_validation :normalize_auto_resolve_after
|
||||
|
||||
validates :name, presence: true
|
||||
validates :description, presence: true, length: { maximum: DESCRIPTION_LENGTH_LIMIT }
|
||||
@@ -55,6 +60,14 @@ class Captain::Assistant < ApplicationRecord
|
||||
validates_with Captain::AudienceValidator
|
||||
validate :validate_response_window
|
||||
validates :auto_resolve_mode, inclusion: { in: AUTO_RESOLVE_MODES }
|
||||
validates :send_inactivity_resolution_message, inclusion: { in: [true, false] }
|
||||
validates :auto_resolve_after,
|
||||
numericality: {
|
||||
only_integer: true,
|
||||
greater_than_or_equal_to: MINIMUM_INACTIVITY_THRESHOLD_MINUTES,
|
||||
less_than_or_equal_to: MAXIMUM_INACTIVITY_THRESHOLD_MINUTES
|
||||
},
|
||||
allow_nil: true
|
||||
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
|
||||
@@ -96,6 +109,22 @@ class Captain::Assistant < ApplicationRecord
|
||||
auto_resolve_mode == 'evaluated'
|
||||
end
|
||||
|
||||
def inactivity_threshold_minutes
|
||||
return DEFAULT_INACTIVITY_THRESHOLD_MINUTES unless account.feature_enabled?('captain_integration_v2')
|
||||
|
||||
(config['auto_resolve_after'] || DEFAULT_INACTIVITY_THRESHOLD_MINUTES).to_i
|
||||
end
|
||||
|
||||
def send_inactivity_resolution_message
|
||||
return true unless account.feature_enabled?('captain_integration_v2')
|
||||
|
||||
config.fetch('send_inactivity_resolution_message', true)
|
||||
end
|
||||
|
||||
def send_inactivity_resolution_message?
|
||||
send_inactivity_resolution_message
|
||||
end
|
||||
|
||||
def available_agent_tools
|
||||
tools = self.class.built_in_agent_tools.dup
|
||||
|
||||
@@ -152,6 +181,14 @@ class Captain::Assistant < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
def normalize_auto_resolve_after
|
||||
threshold = Integer(auto_resolve_after.to_s, exception: false)
|
||||
return unless threshold&.between?(MINIMUM_INACTIVITY_THRESHOLD_MINUTES, MAXIMUM_INACTIVITY_THRESHOLD_MINUTES)
|
||||
|
||||
# Keep API values aligned with the five minute options available in the settings UI.
|
||||
self.auto_resolve_after = (threshold.fdiv(INACTIVITY_THRESHOLD_STEP_MINUTES).round * INACTIVITY_THRESHOLD_STEP_MINUTES)
|
||||
end
|
||||
|
||||
def validate_response_window
|
||||
response_window = config['response_window']
|
||||
return if response_window.blank?
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
json.account_id resource.account_id
|
||||
json.config resource.config.merge('auto_resolve_mode' => resource.auto_resolve_mode)
|
||||
json.config resource.config.merge(
|
||||
'auto_resolve_mode' => resource.auto_resolve_mode,
|
||||
'auto_resolve_after' => resource.inactivity_threshold_minutes,
|
||||
'send_inactivity_resolution_message' => resource.send_inactivity_resolution_message?
|
||||
)
|
||||
json.created_at resource.created_at.to_i
|
||||
json.description resource.description
|
||||
json.guardrails resource.guardrails
|
||||
|
||||
@@ -255,6 +255,61 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
|
||||
expect(assistant.reload.config).to include('product_name' => 'Chatwoot', 'auto_resolve_mode' => 'disabled')
|
||||
end
|
||||
|
||||
it 'keeps inactivity timer settings behind Captain V2' do
|
||||
account.disable_features!('captain_integration_v2')
|
||||
assistant.update!(
|
||||
config: {
|
||||
'auto_resolve_mode' => 'evaluated',
|
||||
'auto_resolve_after' => 60,
|
||||
'send_inactivity_resolution_message' => true
|
||||
}
|
||||
)
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}",
|
||||
params: {
|
||||
assistant: {
|
||||
config: {
|
||||
auto_resolve_mode: 'disabled',
|
||||
auto_resolve_after: 90,
|
||||
send_inactivity_resolution_message: false
|
||||
}
|
||||
}
|
||||
},
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(assistant.reload.config).to include(
|
||||
'auto_resolve_mode' => 'disabled',
|
||||
'auto_resolve_after' => 60,
|
||||
'send_inactivity_resolution_message' => true
|
||||
)
|
||||
end
|
||||
|
||||
it 'updates inactive conversation settings for Captain v2' do
|
||||
account.enable_features!('captain_integration_v2')
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}",
|
||||
params: {
|
||||
assistant: {
|
||||
config: {
|
||||
auto_resolve_after: 61,
|
||||
send_inactivity_resolution_message: false,
|
||||
resolution_message: 'Saved closing message'
|
||||
}
|
||||
}
|
||||
},
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:config]).to include(
|
||||
auto_resolve_after: 60,
|
||||
send_inactivity_resolution_message: false,
|
||||
resolution_message: 'Saved closing message'
|
||||
)
|
||||
end
|
||||
|
||||
it 'persists the nested audience condition tree' do
|
||||
create(:custom_attribute_definition, account: account, attribute_model: :contact_attribute,
|
||||
attribute_display_type: :text, attribute_key: 'plan_tier')
|
||||
|
||||
@@ -78,6 +78,25 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
|
||||
|
||||
expect(Captain::ConversationCompletionService).not_to have_received(:new)
|
||||
end
|
||||
|
||||
it 'uses the assistant inactivity timer' do
|
||||
captain_assistant.account.enable_features!('captain_integration_v2')
|
||||
captain_assistant.update!(config: captain_assistant.config.merge('auto_resolve_after' => 180))
|
||||
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(resolvable_pending_conversation.reload.status).to eq('pending')
|
||||
end
|
||||
|
||||
it 'resolves silently when the resolution message is disabled' do
|
||||
captain_assistant.account.enable_features!('captain_integration_v2')
|
||||
captain_assistant.update!(config: captain_assistant.config.merge('send_inactivity_resolution_message' => false))
|
||||
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(resolvable_pending_conversation.reload.status).to eq('resolved')
|
||||
expect(resolvable_pending_conversation.messages.outgoing).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'when captain_tasks is enabled' do
|
||||
|
||||
@@ -6,6 +6,47 @@ RSpec.describe Captain::Assistant, type: :model do
|
||||
let(:contact) { create(:contact, account: account, additional_attributes: { 'country_code' => 'US' }) }
|
||||
let(:conversation) { create(:conversation, account: account, contact: contact) }
|
||||
|
||||
describe 'inactive conversation settings' do
|
||||
it 'uses safe defaults when settings are unavailable' do
|
||||
assistant.account.enable_features('captain_integration_v2')
|
||||
assistant.auto_resolve_after = nil
|
||||
|
||||
expect(assistant.inactivity_threshold_minutes).to eq(60)
|
||||
|
||||
assistant.auto_resolve_after = 5
|
||||
assistant.send_inactivity_resolution_message = false
|
||||
assistant.account.disable_features('captain_integration_v2')
|
||||
|
||||
expect(assistant.inactivity_threshold_minutes).to eq(60)
|
||||
expect(assistant.send_inactivity_resolution_message?).to be(true)
|
||||
end
|
||||
|
||||
it 'validates the inactivity timer range' do
|
||||
assistant.auto_resolve_after = 4
|
||||
|
||||
expect(assistant).not_to be_valid
|
||||
expect(assistant.errors[:auto_resolve_after]).to be_present
|
||||
|
||||
assistant.auto_resolve_after = 61.5
|
||||
|
||||
expect(assistant).not_to be_valid
|
||||
expect(assistant.errors[:auto_resolve_after]).to be_present
|
||||
end
|
||||
|
||||
it 'rounds the inactivity timer to the nearest five minutes' do
|
||||
assistant.auto_resolve_after = 61
|
||||
|
||||
assistant.validate
|
||||
|
||||
expect(assistant.auto_resolve_after).to eq(60)
|
||||
|
||||
assistant.auto_resolve_after = 63
|
||||
assistant.validate
|
||||
|
||||
expect(assistant.auto_resolve_after).to eq(65)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#responds_to_audience?' do
|
||||
it 'returns true when no audience is configured' do
|
||||
expect(assistant.responds_to_audience?(contact, conversation)).to be(true)
|
||||
|
||||
Reference in New Issue
Block a user