From 0f3bb640f557dea847eaf900883a995027fb7e90 Mon Sep 17 00:00:00 2001 From: Pranav Date: Fri, 7 Aug 2026 00:55:48 -0700 Subject: [PATCH] feat(captain): Add audience and schedule controls for assistants (#14902) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captain assistants now support **audience** and **schedule** controls, so you can decide *who* an assistant replies to and *when* it's on duty. By default nothing changes, an assistant still responds to every conversation in its connected inboxes but you can now narrow that down. - **Audience**: build a condition tree (contact attributes, conversation attributes, and custom attributes) with and/or groups, mirroring the contact-segment filter semantics. Only conversations whose contact matches the audience get a Captain reply. - **Schedule**: choose when Captain replies — *Anytime*, *During business hours*, or *Outside business hours* (based on each inbox's configured working hours; inboxes without business hours are always covered). When an assistant opts out of a conversation (contact outside the audience, or off-schedule), the conversation is routed to the human queue instead of being parked pending on a silent bot — both on initial creation and on reopen. Fixes https://linear.app/chatwoot/issue/CW-7414/audience-and-availability-controls |Audience|Availability| |--|--| | Screenshot 2026-06-30 at 5 52
09 PM | Screenshot 2026-06-30 at 5 52
13 PM | ## How to test 1. Open **Captain → Assistants → (an assistant) → Settings**. 2. Under **Audience**, add a condition or condition group (e.g. `Contact language equal_to en`) and save. Start a conversation from a contact that does *not* match — Captain should stay silent and the conversation should land in the human (open) queue instead of pending. 3. With a matching contact, Captain should respond as before. 4. Under **Schedule**, pick **During business hours** (or **Outside business hours**) on an inbox that has working hours configured, and confirm Captain only engages within/outside that window. An empty/`Anytime` schedule always responds. --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Co-authored-by: aakashb95 Co-authored-by: iamsivin --- .../settings/AssistantAudienceForm.spec.js | 36 +++ .../settings/AssistantAudienceForm.vue | 195 +++++++++++++ .../settings/AssistantScheduleForm.vue | 73 +++++ .../assistant/settings/SettingsPageLayout.vue | 116 ++++++++ .../settings/audience/AudienceGroup.vue | 167 +++++++++++ .../audience/useAudienceFilterTypes.js | 131 +++++++++ .../audience/useAudienceFilterTypes.spec.js | 98 +++++++ .../components-next/filter/ConditionRow.vue | 2 + .../components-next/filter/contactProvider.js | 10 - .../filter/inputs/FilterSelect.vue | 45 ++- .../components-next/sidebar/Sidebar.vue | 3 + .../i18n/locale/en/advancedFilters.json | 2 + .../i18n/locale/en/integrations.json | 50 ++++ .../captain/assistants/guardrails/Index.vue | 249 +++++++--------- .../captain/assistants/guidelines/Index.vue | 275 ++++++++---------- .../captain/assistants/settings/Audience.vue | 18 ++ .../captain/assistants/settings/Index.vue | 91 ++++++ .../captain/assistants/settings/Schedule.vue | 18 ++ .../captain/assistants/settings/Settings.vue | 196 ------------- .../captain/assistants/settings/System.vue | 21 ++ .../settings/useAssistantSettings.js | 31 ++ .../settings/useAssistantSettings.spec.js | 71 +++++ .../dashboard/captain/captain.routes.js | 31 +- .../accounts/captain/assistants_controller.rb | 15 +- enterprise/app/models/captain/assistant.rb | 32 +- .../app/models/enterprise/conversation.rb | 9 + enterprise/app/models/enterprise/message.rb | 9 + .../app/services/captain/audience_matcher.rb | 180 ++++++++++++ .../response_scheduler_service.rb | 58 ++++ .../hook_execution_service.rb | 62 +--- .../validators/captain/audience_validator.rb | 77 +++++ .../captain/assistants_controller_spec.rb | 41 +++ .../models/captain/assistant_spec.rb | 206 ++++++++++++- .../models/enterprise/conversation_spec.rb | 28 ++ .../services/captain/audience_matcher_spec.rb | 194 ++++++++++++ .../hook_execution_service_spec.rb | 86 ++++++ 36 files changed, 2363 insertions(+), 563 deletions(-) create mode 100644 app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantAudienceForm.spec.js create mode 100644 app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantAudienceForm.vue create mode 100644 app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantScheduleForm.vue create mode 100644 app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/SettingsPageLayout.vue create mode 100644 app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/audience/AudienceGroup.vue create mode 100644 app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/audience/useAudienceFilterTypes.js create mode 100644 app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/audience/useAudienceFilterTypes.spec.js create mode 100644 app/javascript/dashboard/routes/dashboard/captain/assistants/settings/Audience.vue create mode 100644 app/javascript/dashboard/routes/dashboard/captain/assistants/settings/Index.vue create mode 100644 app/javascript/dashboard/routes/dashboard/captain/assistants/settings/Schedule.vue delete mode 100644 app/javascript/dashboard/routes/dashboard/captain/assistants/settings/Settings.vue create mode 100644 app/javascript/dashboard/routes/dashboard/captain/assistants/settings/System.vue create mode 100644 app/javascript/dashboard/routes/dashboard/captain/assistants/settings/useAssistantSettings.js create mode 100644 app/javascript/dashboard/routes/dashboard/captain/assistants/settings/useAssistantSettings.spec.js create mode 100644 enterprise/app/services/captain/audience_matcher.rb create mode 100644 enterprise/app/services/captain/conversation/response_scheduler_service.rb create mode 100644 enterprise/app/validators/captain/audience_validator.rb create mode 100644 spec/enterprise/models/enterprise/conversation_spec.rb create mode 100644 spec/enterprise/services/captain/audience_matcher_spec.rb diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantAudienceForm.spec.js b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantAudienceForm.spec.js new file mode 100644 index 000000000..21b42e580 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantAudienceForm.spec.js @@ -0,0 +1,36 @@ +import { nextTick } from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import { describe, expect, it, vi } from 'vitest'; +import Button from 'dashboard/components-next/button/Button.vue'; +import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue'; +import AssistantAudienceForm from './AssistantAudienceForm.vue'; + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: key => key }), +})); + +vi.mock('./audience/useAudienceFilterTypes.js', () => ({ + useAudienceFilterTypes: () => ({ filterTypes: [] }), +})); + +const mountComponent = () => + shallowMount(AssistantAudienceForm, { + props: { assistant: { config: {} } }, + global: { stubs: { RadioCard: false } }, + }); + +describe('AssistantAudienceForm', () => { + it('does not submit a specific audience without conditions', async () => { + const wrapper = mountComponent(); + + wrapper.findAllComponents(RadioCard)[1].vm.$emit('select', 'specific'); + await nextTick(); + wrapper.findComponent(Button).vm.$emit('click'); + await nextTick(); + + expect(wrapper.emitted('submit')).toBeUndefined(); + expect(wrapper.text()).toContain( + 'CAPTAIN.ASSISTANTS.FORM.AUDIENCE.EMPTY_ERROR' + ); + }); +}); diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantAudienceForm.vue b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantAudienceForm.vue new file mode 100644 index 000000000..b07cf7634 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantAudienceForm.vue @@ -0,0 +1,195 @@ + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantScheduleForm.vue b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantScheduleForm.vue new file mode 100644 index 000000000..10e304dc2 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantScheduleForm.vue @@ -0,0 +1,73 @@ + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/SettingsPageLayout.vue b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/SettingsPageLayout.vue new file mode 100644 index 000000000..fbb499f47 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/SettingsPageLayout.vue @@ -0,0 +1,116 @@ + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/audience/AudienceGroup.vue b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/audience/AudienceGroup.vue new file mode 100644 index 000000000..9b6cf1f28 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/audience/AudienceGroup.vue @@ -0,0 +1,167 @@ + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/audience/useAudienceFilterTypes.js b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/audience/useAudienceFilterTypes.js new file mode 100644 index 000000000..07b13d8d8 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/audience/useAudienceFilterTypes.js @@ -0,0 +1,131 @@ +import { computed } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { useMapGetter } from 'dashboard/composables/store.js'; +import { useContactFilterContext } from 'dashboard/components-next/filter/contactProvider.js'; +import { useOperators } from 'dashboard/components-next/filter/operators.js'; +import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js'; + +const STANDARD_ICONS = { + name: 'i-lucide-user', + email: 'i-lucide-mail', + phone_number: 'i-lucide-phone', + identifier: 'i-lucide-fingerprint', + country_code: 'i-lucide-flag', + city: 'i-lucide-map-pin', + company_name: 'i-lucide-building-2', + created_at: 'i-lucide-calendar', + last_activity_at: 'i-lucide-activity', + blocked: 'i-lucide-ban', + labels: 'i-lucide-tags', + hmac_verified: 'i-lucide-user-check', + browser_language: 'i-lucide-globe', +}; + +const CUSTOM_TYPE_ICONS = { + text: 'i-lucide-type', + number: 'i-lucide-hash', + currency: 'i-lucide-banknote', + percent: 'i-lucide-percent', + link: 'i-lucide-link', + date: 'i-lucide-calendar', + list: 'i-lucide-list', + checkbox: 'i-lucide-square-check', +}; + +const DEFAULT_ICON = 'i-lucide-tag'; + +const header = (id, label) => ({ + value: `__group_${id}`, + label, + disabled: true, +}); + +export function useAudienceFilterTypes() { + const { t } = useI18n(); + const { filterTypes: contactFilterTypes } = useContactFilterContext(); + const { equalityOperators } = useOperators(); + const contactAttributes = useMapGetter('attributes/getContactAttributes'); + + const customTypeByKey = computed(() => + Object.fromEntries( + (contactAttributes.value || []).map(attr => [ + attr.attributeKey, + attr.attributeDisplayType, + ]) + ) + ); + + const conversationOption = (key, label, options) => ({ + attributeKey: key, + value: key, + attributeName: label, + label, + icon: STANDARD_ICONS[key], + inputType: 'searchSelect', + options, + dataType: 'text', + filterOperators: equalityOperators.value, + attributeModel: 'additional', + }); + + const conversationTypes = computed(() => [ + conversationOption( + 'hmac_verified', + t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.LOGGED_IN'), + [ + { + id: 'true', + name: t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.LOGGED_IN_TRUE'), + }, + { + id: 'false', + name: t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.LOGGED_IN_FALSE'), + }, + ] + ), + conversationOption( + 'browser_language', + t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.BROWSER_LANGUAGE'), + languages + ), + ]); + + const standardTypes = computed(() => + contactFilterTypes.value + .filter(type => type.attributeModel !== 'customAttributes') + .map(type => ({ + ...type, + icon: STANDARD_ICONS[type.attributeKey] || DEFAULT_ICON, + })) + ); + + const customTypes = computed(() => + contactFilterTypes.value + .filter(type => type.attributeModel === 'customAttributes') + .filter(type => type.attributeKey in customTypeByKey.value) + .map(type => ({ + ...type, + icon: + CUSTOM_TYPE_ICONS[customTypeByKey.value[type.attributeKey]] || + DEFAULT_ICON, + })) + ); + + const filterTypes = computed(() => [ + header('contact', t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.GROUP_CONTACT')), + ...standardTypes.value, + header( + 'conversation', + t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.GROUP_CONVERSATION') + ), + ...conversationTypes.value, + ...(customTypes.value.length + ? [ + header('custom', t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.GROUP_CUSTOM')), + ...customTypes.value, + ] + : []), + ]); + + return { filterTypes }; +} diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/audience/useAudienceFilterTypes.spec.js b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/audience/useAudienceFilterTypes.spec.js new file mode 100644 index 000000000..0818d9264 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/audience/useAudienceFilterTypes.spec.js @@ -0,0 +1,98 @@ +import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js'; +import { useAudienceFilterTypes } from './useAudienceFilterTypes'; + +const state = vi.hoisted(() => ({ + contactFilterTypes: [], + contactAttributes: [], + equalityOperators: [{ value: 'equal_to' }, { value: 'not_equal_to' }], +})); + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: key => key }), +})); + +vi.mock('dashboard/composables/store.js', () => ({ + useMapGetter: () => ({ value: state.contactAttributes }), +})); + +vi.mock('dashboard/components-next/filter/contactProvider.js', () => ({ + useContactFilterContext: () => ({ + filterTypes: { value: state.contactFilterTypes }, + }), +})); + +vi.mock('dashboard/components-next/filter/operators.js', () => ({ + useOperators: () => ({ + equalityOperators: { value: state.equalityOperators }, + }), +})); + +describe('useAudienceFilterTypes', () => { + beforeEach(() => { + state.contactFilterTypes = [ + { attributeKey: 'email', attributeModel: 'standard' }, + { attributeKey: 'mystery', attributeModel: 'standard' }, + { attributeKey: 'signed_up_on', attributeModel: 'customAttributes' }, + { attributeKey: 'company_size', attributeModel: 'customAttributes' }, + ]; + state.contactAttributes = [ + { attributeKey: 'signed_up_on', attributeDisplayType: 'date' }, + ]; + }); + + const build = () => useAudienceFilterTypes().filterTypes.value; + + it('assembles grouped sections in order', () => { + const keys = build().map(item => item.attributeKey ?? item.value); + expect(keys).toEqual([ + '__group_contact', + 'email', + 'mystery', + '__group_conversation', + 'hmac_verified', + 'browser_language', + '__group_custom', + 'signed_up_on', + ]); + }); + + it('marks headers as disabled options', () => { + const headers = build().filter(item => item.value?.startsWith('__group_')); + expect(headers).toHaveLength(3); + expect(headers.every(header => header.disabled)).toBe(true); + }); + + it('excludes custom attributes that are not contact-model attributes', () => { + const keys = build().map(item => item.attributeKey); + expect(keys).not.toContain('company_size'); + }); + + it('omits the custom section when there are no contact custom attributes', () => { + state.contactAttributes = []; + const values = build().map(item => item.attributeKey ?? item.value); + expect(values).not.toContain('__group_custom'); + expect(values).not.toContain('signed_up_on'); + }); + + it('assigns icons by attribute key with a default fallback', () => { + const byKey = Object.fromEntries( + build().map(item => [item.attributeKey, item.icon]) + ); + expect(byKey.email).toBe('i-lucide-mail'); + expect(byKey.mystery).toBe('i-lucide-tag'); + expect(byKey.signed_up_on).toBe('i-lucide-calendar'); + }); + + it('builds conversation options as searchSelects with equality operators', () => { + const types = build(); + const hmac = types.find(item => item.attributeKey === 'hmac_verified'); + expect(hmac.inputType).toBe('searchSelect'); + expect(hmac.filterOperators).toEqual(state.equalityOperators); + expect(hmac.options.map(option => option.id)).toEqual(['true', 'false']); + + const browserLanguage = types.find( + item => item.attributeKey === 'browser_language' + ); + expect(browserLanguage.options).toEqual(languages); + }); +}); diff --git a/app/javascript/dashboard/components-next/filter/ConditionRow.vue b/app/javascript/dashboard/components-next/filter/ConditionRow.vue index 30709babd..e28c744e0 100644 --- a/app/javascript/dashboard/components-next/filter/ConditionRow.vue +++ b/app/javascript/dashboard/components-next/filter/ConditionRow.vue @@ -15,6 +15,7 @@ import { validateSingleFilter } from 'dashboard/helper/validations.js'; const { filterTypes } = defineProps({ showQueryOperator: { type: Boolean, default: false }, filterTypes: { type: Array, required: true }, + searchableAttributes: { type: Boolean, default: false }, }); const emit = defineEmits(['remove']); @@ -199,6 +200,7 @@ defineExpose({ validate, resetValidation }); v-model="attributeKey" variant="faded" :options="filterTypes" + :searchable="searchableAttributes" @update:model-value="resetModelOnAttributeKeyChange" /> import { computed, ref } from 'vue'; +import { useI18n } from 'vue-i18n'; import { useElementBounding, useWindowSize } from '@vueuse/core'; import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue'; import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue'; @@ -7,6 +8,7 @@ import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue'; import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue'; import Button from 'next/button/Button.vue'; +import Icon from 'next/icon/Icon.vue'; // [{label, icon, value}] const props = defineProps({ @@ -30,6 +32,10 @@ const props = defineProps({ type: String, default: null, }, + searchable: { + type: Boolean, + default: false, + }, }); const selected = defineModel({ @@ -37,9 +43,23 @@ const selected = defineModel({ required: true, }); +const vFocus = { mounted: el => el.focus() }; + +const { t } = useI18n(); + +const searchQuery = ref(''); const triggerRef = ref(null); const dropdownRef = ref(null); +const filteredOptions = computed(() => { + const query = searchQuery.value.trim().toLowerCase(); + if (!props.searchable || !query) return props.options; + return props.options.filter( + option => + !option.disabled && (option.label || '').toLowerCase().includes(query) + ); +}); + const { top } = useElementBounding(triggerRef); const { height } = useWindowSize(); const { height: dropdownHeight } = useElementBounding(dropdownRef); @@ -65,6 +85,12 @@ const dropdownPosition = computed(() => { const updateSelected = newValue => { selected.value = newValue; + searchQuery.value = ''; +}; + +const handleTriggerClick = toggle => { + searchQuery.value = ''; + toggle(); }; @@ -81,7 +107,7 @@ const updateSelected = newValue => { :icon="iconToRender" :trailing-icon="selectedOption.icon ? false : true" :label="label || (hideLabel ? null : selectedOption.label)" - @click="toggle" + @click="handleTriggerClick(toggle)" /> @@ -91,8 +117,17 @@ const updateSelected = newValue => { :class="dropdownPosition" strong > +
+ + +
-