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:
Aakash Bakhle
2026-08-10 17:33:04 +05:30
committed by GitHub
parent 9a9c88494e
commit bdbbaa38de
12 changed files with 394 additions and 28 deletions

View File

@@ -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"

View File

@@ -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')"

View File

@@ -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':

View File

@@ -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",

View File

@@ -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"

View File

@@ -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)

View File

@@ -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!(

View File

@@ -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?

View File

@@ -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

View File

@@ -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')

View File

@@ -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

View File

@@ -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)