Compare commits

...
Author SHA1 Message Date
Pranav 5029b25d5c fix(captain): keep responding to pending conversations on audience or schedule drift 2026-07-16 20:38:27 -07:00
Pranav 304777e728 Merge branch 'develop' into feat/captain-audience-schedule 2026-07-16 20:21:42 -07:00
Pranav fef3190be5 fix(captain): treat missing checkbox attributes as false in audience matcher
An unset checkbox custom attribute now matches equal_to false and
not_equal_to true instead of never matching.
2026-07-14 18:38:27 -07:00
Pranav 3cd3d653a5 refactor(captain): split useAudienceFilterTypes into smaller computeds
Extracts standard/custom/conversation sections into named computeds with
a shared option factory, and adds specs for the composable.
2026-07-14 18:13:34 -07:00
Pranav aae21dfbb8 refactor(captain): simplify useAudienceFilterTypes composable 2026-07-14 17:53:50 -07:00
Pranav 2dec6ef362 fix(captain): match all selected labels in audience conditions
The labels multi-select serializes every selected label into values, but
the matcher only compared values.first. equal_to now matches contacts
carrying any selected label and not_equal_to rejects them.
2026-07-14 17:53:42 -07:00
Pranav cba79636cb fix(captain): compare date custom attributes stored as strings in audience matcher
Custom date attributes persist as ISO strings in jsonb, so is_greater_than/
is_less_than fell into the BigDecimal branch and never matched. Parse ISO
date strings as times, mirroring Contacts::FilterService coercion.
2026-07-14 17:45:12 -07:00
Pranav bc79897419 refactor(captain): simplify audience param permitting and engagement check
Drops the unreachable permitted[:config] fallback in the assistants
controller and the redundant respond_to? guard in the hook execution
service's captain_should_engage?.
2026-07-14 17:34:26 -07:00
Pranav 71ba10d0fa refactor(captain): trim comments in AudienceMatcher 2026-07-14 17:19:01 -07:00
Pranav b7ac1c28fb refactor(captain): extract audience validation into Captain::AudienceValidator
Moves the audience condition-tree validation out of Captain::Assistant into
a dedicated ActiveModel validator, and expands spec coverage for audience
and response_window validation.
2026-07-14 17:06:51 -07:00
Pranav 0e2d577d43 refactor(captain): tidy comments and naming in assistant engagement code 2026-07-14 16:40:17 -07:00
Pranav 6bbb763d4a refactor(captain): inline engagement check in Enterprise::Conversation
Drop the diff comments and the one-use captain_should_engage? helper;
determine_conversation_status now checks the assistant inline.
2026-07-14 16:34:14 -07:00
Pranav cfcbe707e3 refactor(captain): simplify reopen engagement check in Enterprise::Message
The respond_to?(:captain_assistant) guard was redundant — the enterprise
message override and the inbox concern defining the association are loaded
under the same enterprise gate, so the association always exists here.
2026-07-14 16:30:14 -07:00
Pranav 94fe5318b4 Merge branch 'develop' into feat/captain-audience-schedule 2026-07-14 16:01:48 -07:00
Pranav 8def9220a9 Update audience matcher 2026-07-07 06:34:25 -07:00
PranavandGitHub 59a88dae9f Merge branch 'develop' into feat/captain-audience-schedule 2026-07-06 07:10:08 -07:00
PranavandClaude Opus 4.8 38fccdd04b feat(captain): toggle assistant audience between everyone and specific
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:58:07 -07:00
Pranav 9b54bc5096 Merge branch 'develop' into feat/captain-audience-schedule 2026-06-30 17:36:04 -07:00
Pranav 39606f3944 feat(captain): add audience and schedule controls for assistants
Audience: a nested condition builder (contact attributes, labels, conversation language, and a logged-in/HMAC-verified flag) evaluated in-memory by Captain::AudienceMatcher. Gates Captain at the reply, new-conversation routing, reopen, and template-suppression sites so out-of-audience conversations go to the human queue.

Schedule: a response window (anytime / during business hours / outside business hours) reusing each inbox's business hours. Off-schedule conversations are handed to the team; inboxes without business hours are always covered.
2026-06-24 07:19:52 -07:00
24 changed files with 1582 additions and 91 deletions
@@ -0,0 +1,173 @@
<script setup>
import { ref, watch, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
import AudienceGroup from './audience/AudienceGroup.vue';
import { useAudienceFilterTypes } from './audience/useAudienceFilterTypes.js';
const props = defineProps({
assistant: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['submit']);
const { t } = useI18n();
const { filterTypes } = useAudienceFilterTypes();
let uid = 0;
const nextId = () => {
uid += 1;
return `audience-root-${uid}`;
};
const hasConditions = node =>
node && Object.prototype.hasOwnProperty.call(node, 'conditions');
const defaultRoot = () => ({ id: nextId(), operator: 'and', conditions: [] });
const root = ref(defaultRoot());
const mode = ref('everyone');
const findOption = (filterType, value) =>
filterType?.options?.find(option => String(option.id) === String(value));
const hydrateValues = (leaf, filterType) => {
const raw = Array.isArray(leaf.values) ? leaf.values : [leaf.values];
const inputType = filterType?.inputType;
if (inputType === 'multiSelect') {
return raw.map(
value => findOption(filterType, value) ?? { id: value, name: value }
);
}
if (['searchSelect', 'booleanSelect'].includes(inputType)) {
return findOption(filterType, raw[0]) ?? { id: raw[0], name: raw[0] };
}
return raw[0] ?? '';
};
const hydrateNode = node => {
if (hasConditions(node)) {
return {
id: nextId(),
operator: node.operator || 'and',
conditions: (node.conditions || []).map(hydrateNode),
};
}
const filterType = filterTypes.value.find(
type => type.attributeKey === node.attribute_key
);
return {
id: nextId(),
attributeKey: node.attribute_key,
filterOperator: node.filter_operator,
values: hydrateValues(node, filterType),
attributeModel: filterType?.attributeModel || 'standard',
};
};
const hydrateRoot = audience => {
if (!audience) return defaultRoot();
const hydrated = hydrateNode(audience);
return hasConditions(hydrated)
? hydrated
: { id: nextId(), operator: 'and', conditions: [hydrated] };
};
const serializeValues = values => {
if (Array.isArray(values)) {
return values[0]?.id ? values.map(value => value.id) : values;
}
if (values && typeof values === 'object') {
return [values.id];
}
if (values === '' || values === null || values === undefined) {
return [];
}
return [values];
};
const serializeNode = node => {
if (hasConditions(node)) {
return {
operator: node.operator,
conditions: node.conditions.map(serializeNode),
};
}
return {
attribute_key: node.attributeKey,
filter_operator: node.filterOperator,
values: serializeValues(node.values),
};
};
const groupRef = useTemplateRef('groupRef');
const handleSubmit = () => {
const isSpecific = mode.value === 'specific';
if (isSpecific && !groupRef.value.validate()) return;
const audience =
isSpecific && root.value.conditions.length
? serializeNode(root.value)
: null;
emit('submit', {
config: {
...props.assistant.config,
audience,
},
});
};
watch(
() => props.assistant,
newAssistant => {
if (!newAssistant) return;
const audience = newAssistant.config?.audience;
root.value = hydrateRoot(audience);
mode.value = audience ? 'specific' : 'everyone';
},
{ immediate: true }
);
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3">
<RadioCard
id="everyone"
:label="t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.EVERYONE.LABEL')"
:description="t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.EVERYONE.DESC')"
:is-active="mode === 'everyone'"
@select="mode = 'everyone'"
/>
<RadioCard
id="specific"
:label="t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.SPECIFIC.LABEL')"
:description="t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.SPECIFIC.DESC')"
:is-active="mode === 'specific'"
@select="mode = 'specific'"
>
<AudienceGroup
v-if="mode === 'specific'"
ref="groupRef"
v-model="root"
is-root
class="w-full mt-2"
:filter-types="filterTypes"
/>
</RadioCard>
</div>
<div>
<Button
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@click="handleSubmit"
/>
</div>
</div>
</template>
@@ -0,0 +1,66 @@
<script setup>
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
const props = defineProps({
assistant: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['submit']);
const { t } = useI18n();
const OPTIONS = ['always', 'business_hours', 'outside_business_hours'];
const selected = ref('always');
const handleSubmit = () => {
emit('submit', {
config: { ...props.assistant.config, response_window: selected.value },
});
};
watch(
() => props.assistant,
newAssistant => {
if (newAssistant) {
selected.value = newAssistant.config?.response_window || 'always';
}
},
{ immediate: true }
);
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3">
<RadioCard
v-for="option in OPTIONS"
:id="option"
:key="option"
:label="
t(`CAPTAIN.ASSISTANTS.FORM.SCHEDULE.${option.toUpperCase()}.LABEL`)
"
:description="
t(`CAPTAIN.ASSISTANTS.FORM.SCHEDULE.${option.toUpperCase()}.DESC`)
"
:is-active="selected === option"
@select="selected = $event"
/>
</div>
<p class="text-sm text-n-slate-11">
{{ t('CAPTAIN.ASSISTANTS.FORM.SCHEDULE.HINT') }}
</p>
<div>
<Button
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@click="handleSubmit"
/>
</div>
</div>
</template>
@@ -0,0 +1,167 @@
<script setup>
import { useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'next/button/Button.vue';
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
import ConditionRow from 'dashboard/components-next/filter/ConditionRow.vue';
const props = defineProps({
filterTypes: { type: Array, required: true },
depth: { type: Number, default: 0 },
maxDepth: { type: Number, default: 1 },
isRoot: { type: Boolean, default: false },
});
const emit = defineEmits(['remove']);
const node = defineModel({ type: Object, required: true });
const { t } = useI18n();
let uid = 0;
const nextId = () => {
uid += 1;
return `audience-${Date.now()}-${uid}`;
};
const DEFAULT_LEAF = () => ({
id: nextId(),
attributeKey: 'email',
filterOperator: 'contains',
values: '',
attributeModel: 'standard',
});
const operatorOptions = [
{ value: 'and', label: t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.MATCH_ALL') },
{ value: 'or', label: t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.MATCH_ANY') },
];
const isGroup = child =>
Object.prototype.hasOwnProperty.call(child, 'conditions');
const addCondition = () => {
node.value.conditions.push(DEFAULT_LEAF());
};
const addGroup = () => {
node.value.conditions.push({
id: nextId(),
operator: 'and',
conditions: [DEFAULT_LEAF()],
});
};
const removeChild = index => {
node.value.conditions.splice(index, 1);
// A nested group must always hold at least one condition; remove the whole group when emptied.
if (!props.isRoot && node.value.conditions.length === 0) {
emit('remove');
}
};
const leafRefs = useTemplateRef('leafRefs');
const groupRefs = useTemplateRef('groupRefs');
const validate = () => {
const leavesValid = (leafRefs.value ?? []).every(row => row.validate());
const groupsValid = (groupRefs.value ?? []).every(group => group.validate());
return leavesValid && groupsValid;
};
defineExpose({ validate });
</script>
<template>
<div
class="flex flex-col gap-4 group/audience-group"
:class="
isRoot
? 'p-4 border border-n-weak rounded-xl'
: 'p-3 border border-n-weak bg-n-alpha-1 rounded-lg'
"
>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1.5 text-sm text-n-slate-11">
<span>{{ t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.MATCH_PREFIX') }}</span>
<FilterSelect
v-model="node.operator"
variant="faded"
hide-icon
class="text-sm"
:options="operatorOptions"
/>
<span>{{ t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.MATCH_SUFFIX') }}</span>
</div>
<Button
v-if="!isRoot"
sm
ghost
slate
icon="i-lucide-trash"
class="ml-auto flex-shrink-0 opacity-0 transition-opacity group-hover/audience-group:opacity-100"
@click="emit('remove')"
/>
</div>
<ul class="grid gap-3 list-none">
<template v-for="(child, index) in node.conditions" :key="child.id">
<AudienceGroup
v-if="isGroup(child)"
ref="groupRefs"
v-model="node.conditions[index]"
:filter-types="filterTypes"
:depth="depth + 1"
:max-depth="maxDepth"
@remove="removeChild(index)"
/>
<ConditionRow
v-else
ref="leafRefs"
v-model:attribute-key="child.attributeKey"
v-model:filter-operator="child.filterOperator"
v-model:values="child.values"
:filter-types="filterTypes"
:show-query-operator="false"
searchable-attributes
@remove="removeChild(index)"
/>
</template>
</ul>
<div
v-if="isRoot && !node.conditions.length"
class="flex flex-col items-center gap-1.5 px-4 py-8 text-center border border-dashed rounded-lg border-n-weak"
>
<span class="text-2xl i-lucide-users-round text-n-slate-10" />
<p class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.EMPTY_TITLE') }}
</p>
<p class="max-w-md text-sm text-n-slate-11">
{{ t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.EMPTY_BODY') }}
</p>
</div>
<div class="flex gap-2">
<Button
sm
ghost
blue
icon="i-lucide-plus"
class="flex-shrink-0"
:label="t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.ADD_CONDITION')"
@click="addCondition"
/>
<Button
v-if="depth < maxDepth"
sm
ghost
blue
icon="i-lucide-plus"
class="flex-shrink-0"
:label="t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.ADD_GROUP')"
@click="addGroup"
/>
</div>
</div>
</template>
@@ -0,0 +1,137 @@
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',
conversation_language: 'i-lucide-languages',
};
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
),
conversationOption(
'conversation_language',
t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.CONVERSATION_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 };
}
@@ -0,0 +1,99 @@
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',
'conversation_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);
});
});
@@ -15,6 +15,7 @@ import { validateSingleFilter } from 'dashboard/helper/validations.js';
const { filterTypes } = defineProps({ const { filterTypes } = defineProps({
showQueryOperator: { type: Boolean, default: false }, showQueryOperator: { type: Boolean, default: false },
filterTypes: { type: Array, required: true }, filterTypes: { type: Array, required: true },
searchableAttributes: { type: Boolean, default: false },
}); });
const emit = defineEmits(['remove']); const emit = defineEmits(['remove']);
@@ -199,6 +200,7 @@ defineExpose({ validate, resetValidation });
v-model="attributeKey" v-model="attributeKey"
variant="faded" variant="faded"
:options="filterTypes" :options="filterTypes"
:searchable="searchableAttributes"
@update:model-value="resetModelOnAttributeKeyChange" @update:model-value="resetModelOnAttributeKeyChange"
/> />
<FilterSelect <FilterSelect
@@ -165,16 +165,6 @@ export function useContactFilterContext() {
filterOperators: dateOperators.value, filterOperators: dateOperators.value,
attributeModel: 'standard', attributeModel: 'standard',
}, },
{
attributeKey: CONTACT_ATTRIBUTES.REFERER,
value: CONTACT_ATTRIBUTES.REFERER,
attributeName: t('CONTACTS_LAYOUT.FILTER.REFERER_LINK'),
label: t('CONTACTS_LAYOUT.FILTER.REFERER_LINK'),
inputType: 'plainText',
dataType: 'text',
filterOperators: containmentOperators.value,
attributeModel: 'standard',
},
{ {
attributeKey: CONTACT_ATTRIBUTES.BLOCKED, attributeKey: CONTACT_ATTRIBUTES.BLOCKED,
value: CONTACT_ATTRIBUTES.BLOCKED, value: CONTACT_ATTRIBUTES.BLOCKED,
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useElementBounding, useWindowSize } from '@vueuse/core'; import { useElementBounding, useWindowSize } from '@vueuse/core';
import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue'; import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue';
import DropdownSection from 'next/dropdown-menu/base/DropdownSection.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 DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
import Button from 'next/button/Button.vue'; import Button from 'next/button/Button.vue';
import Icon from 'next/icon/Icon.vue';
// [{label, icon, value}] // [{label, icon, value}]
const props = defineProps({ const props = defineProps({
@@ -30,6 +32,10 @@ const props = defineProps({
type: String, type: String,
default: null, default: null,
}, },
searchable: {
type: Boolean,
default: false,
},
}); });
const selected = defineModel({ const selected = defineModel({
@@ -37,9 +43,21 @@ const selected = defineModel({
required: true, required: true,
}); });
const { t } = useI18n();
const searchQuery = ref('');
const triggerRef = ref(null); const triggerRef = ref(null);
const dropdownRef = 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 { top } = useElementBounding(triggerRef);
const { height } = useWindowSize(); const { height } = useWindowSize();
const { height: dropdownHeight } = useElementBounding(dropdownRef); const { height: dropdownHeight } = useElementBounding(dropdownRef);
@@ -65,6 +83,12 @@ const dropdownPosition = computed(() => {
const updateSelected = newValue => { const updateSelected = newValue => {
selected.value = newValue; selected.value = newValue;
searchQuery.value = '';
};
const handleTriggerClick = toggle => {
searchQuery.value = '';
toggle();
}; };
</script> </script>
@@ -81,7 +105,7 @@ const updateSelected = newValue => {
:icon="iconToRender" :icon="iconToRender"
:trailing-icon="selectedOption.icon ? false : true" :trailing-icon="selectedOption.icon ? false : true"
:label="label || (hideLabel ? null : selectedOption.label)" :label="label || (hideLabel ? null : selectedOption.label)"
@click="toggle" @click="handleTriggerClick(toggle)"
/> />
</slot> </slot>
</template> </template>
@@ -91,8 +115,17 @@ const updateSelected = newValue => {
:class="dropdownPosition" :class="dropdownPosition"
strong strong
> >
<div v-if="searchable" class="relative">
<Icon class="absolute size-4 left-2 top-2" icon="i-lucide-search" />
<input
v-model="searchQuery"
v-focus
class="w-full p-1.5 pl-8 rounded-lg text-n-slate-11 bg-n-alpha-1"
:placeholder="t('FILTER.SEARCH_PLACEHOLDER')"
/>
</div>
<DropdownSection class="[&>ul]:max-h-72"> <DropdownSection class="[&>ul]:max-h-72">
<template v-for="option in options" :key="option.value"> <template v-for="option in filteredOptions" :key="option.value">
<li <li
v-if="option.disabled" v-if="option.disabled"
class="px-2 py-1.5 text-xs font-medium text-n-slate-10 select-none" class="px-2 py-1.5 text-xs font-medium text-n-slate-10 select-none"
@@ -106,6 +139,12 @@ const updateSelected = newValue => {
@click="updateSelected(option.value)" @click="updateSelected(option.value)"
/> />
</template> </template>
<li
v-if="searchable && !filteredOptions.length"
class="px-2 py-1.5 text-sm text-n-slate-10 select-none"
>
{{ t('FILTER.NO_RESULTS') }}
</li>
</DropdownSection> </DropdownSection>
</DropdownBody> </DropdownBody>
</DropdownContainer> </DropdownContainer>
@@ -19,6 +19,8 @@
"OR": "OR" "OR": "OR"
}, },
"INPUT_PLACEHOLDER": "Enter value", "INPUT_PLACEHOLDER": "Enter value",
"SEARCH_PLACEHOLDER": "Search…",
"NO_RESULTS": "No matches",
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts", "CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
"CONTACT_FALLBACK": "Contact #{id}", "CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": { "OPERATOR_LABELS": {
@@ -609,6 +609,47 @@
"ALLOW_MEMORIES": "Capture key details as memories from customer interactions.", "ALLOW_MEMORIES": "Capture key details as memories from customer interactions.",
"ALLOW_CITATIONS": "Include source citations in responses", "ALLOW_CITATIONS": "Include source citations in responses",
"ALLOW_CONTACT_ATTRIBUTES": "Allow access to contact information" "ALLOW_CONTACT_ATTRIBUTES": "Allow access to contact information"
},
"AUDIENCE": {
"EVERYONE": {
"LABEL": "Everyone",
"DESC": "Captain responds to every conversation in connected inboxes."
},
"SPECIFIC": {
"LABEL": "Specific audience",
"DESC": "Captain responds only to customers who match the conditions below."
},
"ADD_CONDITION": "Add condition",
"ADD_GROUP": "Add condition group",
"EMPTY_TITLE": "No conditions yet",
"EMPTY_BODY": "Add a condition to target specific customers, or a condition group to combine conditions with different and/or logic.",
"MATCH_PREFIX": "Match",
"MATCH_SUFFIX": "of the following",
"MATCH_ALL": "all",
"MATCH_ANY": "any",
"GROUP_CONTACT": "Contact attributes",
"GROUP_CONVERSATION": "Conversation",
"GROUP_CUSTOM": "Custom attributes",
"BROWSER_LANGUAGE": "Browser language",
"CONVERSATION_LANGUAGE": "Conversation language",
"LOGGED_IN": "Logged-in user",
"LOGGED_IN_TRUE": "Yes",
"LOGGED_IN_FALSE": "No"
},
"SCHEDULE": {
"ALWAYS": {
"LABEL": "Anytime",
"DESC": "Captain responds to every conversation, regardless of time."
},
"BUSINESS_HOURS": {
"LABEL": "During business hours",
"DESC": "Captain responds only within each inbox's business hours."
},
"OUTSIDE_BUSINESS_HOURS": {
"LABEL": "Outside business hours",
"DESC": "Captain responds only when each inbox is closed."
},
"HINT": "Inboxes without business hours configured are always covered."
} }
}, },
"EDIT": { "EDIT": {
@@ -627,6 +668,14 @@
"TITLE": "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."
}, },
"AUDIENCE": {
"TITLE": "Audience",
"DESCRIPTION": "Choose whether this assistant responds to everyone or only to a specific audience."
},
"SCHEDULE": {
"TITLE": "Schedule",
"DESCRIPTION": "Choose when Captain replies. Outside this window, conversations are handed to your team."
},
"CONTROL_ITEMS": { "CONTROL_ITEMS": {
"TITLE": "The Fun Stuff", "TITLE": "The Fun Stuff",
"DESCRIPTION": "Add more control to the assistant. (a bit more visual like a story : Query guardrail → scenarios → output) Nudges user to actually utilise these.", "DESCRIPTION": "Add more control to the assistant. (a bit more visual like a story : Query guardrail → scenarios → output) Nudges user to actually utilise these.",
@@ -12,7 +12,8 @@ import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import SettingsHeader from 'dashboard/components-next/captain/pageComponents/settings/SettingsHeader.vue'; import SettingsHeader from 'dashboard/components-next/captain/pageComponents/settings/SettingsHeader.vue';
import AssistantBasicSettingsForm from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantBasicSettingsForm.vue'; import AssistantBasicSettingsForm from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantBasicSettingsForm.vue';
import AssistantSystemSettingsForm from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.vue'; import AssistantSystemSettingsForm from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.vue';
import AssistantControlItems from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantControlItems.vue'; import AssistantAudienceForm from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantAudienceForm.vue';
import AssistantScheduleForm from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantScheduleForm.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue'; import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
const { t } = useI18n(); const { t } = useI18n();
@@ -35,29 +36,56 @@ const assistant = computed(() =>
store.getters['captainAssistants/getRecord'](assistantId.value) store.getters['captainAssistants/getRecord'](assistantId.value)
); );
const controlItems = computed(() => { const activeSection = ref('basic');
return [
const navItems = computed(() => {
const items = [
{ {
name: t( id: 'basic',
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.GUARDRAILS.TITLE' label: t('CAPTAIN.ASSISTANTS.SETTINGS.BASIC_SETTINGS.TITLE'),
),
description: t(
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.GUARDRAILS.DESCRIPTION'
),
routeName: 'captain_assistants_guardrails_index',
}, },
{ {
name: t( id: 'system',
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.RESPONSE_GUIDELINES.TITLE' label: t('CAPTAIN.ASSISTANTS.SETTINGS.SYSTEM_SETTINGS.TITLE'),
),
description: t(
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.RESPONSE_GUIDELINES.DESCRIPTION'
),
routeName: 'captain_assistants_guidelines_index',
}, },
{ id: 'audience', label: t('CAPTAIN.ASSISTANTS.SETTINGS.AUDIENCE.TITLE') },
{ id: 'schedule', label: t('CAPTAIN.ASSISTANTS.SETTINGS.SCHEDULE.TITLE') },
]; ];
if (isCaptainV2Enabled.value) {
items.push(
{
routeName: 'captain_assistants_guardrails_index',
label: t(
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.GUARDRAILS.TITLE'
),
},
{
routeName: 'captain_assistants_guidelines_index',
label: t(
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.RESPONSE_GUIDELINES.TITLE'
),
}
);
}
return items;
}); });
const handleNavClick = item => {
if (item.routeName) {
router.push({
name: item.routeName,
params: {
accountId: route.params.accountId,
assistantId: assistantId.value,
},
});
return;
}
activeSection.value = item.id;
};
const handleSubmit = async updatedAssistant => { const handleSubmit = async updatedAssistant => {
try { try {
await store.dispatch('captainAssistants/update', { await store.dispatch('captainAssistants/update', {
@@ -107,18 +135,30 @@ const handleDeleteSuccess = () => {
:is-fetching="isFetching" :is-fetching="isFetching"
:show-pagination-footer="false" :show-pagination-footer="false"
:show-know-more="false" :show-know-more="false"
:class="{
'[&>header>div]:max-w-[80rem] [&>main>div]:max-w-[80rem]':
isCaptainV2Enabled,
}"
> >
<template #body> <template #body>
<div <div class="flex gap-8 pb-8">
class="gap-6 lg:gap-16 pb-8" <nav
:class="{ 'grid grid-cols-2': isCaptainV2Enabled }" class="sticky self-start flex flex-col flex-shrink-0 w-48 gap-1 top-0"
> >
<div class="flex flex-col gap-6"> <button
<div class="flex flex-col gap-6"> v-for="item in navItems"
:key="item.id ?? item.routeName"
type="button"
class="px-3 py-2 text-sm text-left rounded-lg transition-colors"
:class="
activeSection === item.id
? 'bg-n-alpha-2 text-n-slate-12 font-medium'
: 'text-n-slate-11 hover:bg-n-alpha-1'
"
@click="handleNavClick(item)"
>
{{ item.label }}
</button>
</nav>
<div class="flex flex-col flex-1 min-w-0 gap-6">
<section v-if="activeSection === 'basic'" class="flex flex-col gap-6">
<SettingsHeader <SettingsHeader
:heading="t('CAPTAIN.ASSISTANTS.SETTINGS.BASIC_SETTINGS.TITLE')" :heading="t('CAPTAIN.ASSISTANTS.SETTINGS.BASIC_SETTINGS.TITLE')"
:description=" :description="
@@ -129,9 +169,35 @@ const handleDeleteSuccess = () => {
:assistant="assistant" :assistant="assistant"
@submit="handleSubmit" @submit="handleSubmit"
/> />
</div> <span class="w-full h-px mt-2 bg-n-weak" />
<span class="h-px w-full bg-n-weak mt-2" /> <div class="flex items-end justify-between w-full gap-4">
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.SETTINGS.DELETE.TITLE') }}
</h6>
<span class="text-sm text-n-slate-11">
{{ t('CAPTAIN.ASSISTANTS.SETTINGS.DELETE.DESCRIPTION') }}
</span>
</div>
<div class="flex-shrink-0">
<Button
:label="
t('CAPTAIN.ASSISTANTS.SETTINGS.DELETE.BUTTON_TEXT', {
assistantName: assistant.name,
})
"
color="ruby"
class="max-w-56 !w-fit"
@click="handleDelete"
/>
</div>
</div>
</section>
<section
v-else-if="activeSection === 'system'"
class="flex flex-col gap-6"
>
<SettingsHeader <SettingsHeader
:heading="t('CAPTAIN.ASSISTANTS.SETTINGS.SYSTEM_SETTINGS.TITLE')" :heading="t('CAPTAIN.ASSISTANTS.SETTINGS.SYSTEM_SETTINGS.TITLE')"
:description=" :description="
@@ -142,45 +208,39 @@ const handleDeleteSuccess = () => {
:assistant="assistant" :assistant="assistant"
@submit="handleSubmit" @submit="handleSubmit"
/> />
</div> </section>
<span class="h-px w-full bg-n-weak mt-2" />
<div class="flex items-end justify-between w-full gap-4"> <section
<div class="flex flex-col gap-2"> v-else-if="activeSection === 'audience'"
<h6 class="text-n-slate-12 text-base font-medium"> class="flex flex-col gap-6"
{{ t('CAPTAIN.ASSISTANTS.SETTINGS.DELETE.TITLE') }} >
</h6> <SettingsHeader
<span class="text-n-slate-11 text-sm"> :heading="t('CAPTAIN.ASSISTANTS.SETTINGS.AUDIENCE.TITLE')"
{{ t('CAPTAIN.ASSISTANTS.SETTINGS.DELETE.DESCRIPTION') }} :description="
</span> t('CAPTAIN.ASSISTANTS.SETTINGS.AUDIENCE.DESCRIPTION')
</div> "
<div class="flex-shrink-0">
<Button
:label="
t('CAPTAIN.ASSISTANTS.SETTINGS.DELETE.BUTTON_TEXT', {
assistantName: assistant.name,
})
"
color="ruby"
class="max-w-56 !w-fit"
@click="handleDelete"
/>
</div>
</div>
</div>
<div v-if="isCaptainV2Enabled" class="flex flex-col gap-6">
<SettingsHeader
:heading="t('CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.TITLE')"
:description="
t('CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.DESCRIPTION')
"
/>
<div class="flex flex-col gap-6">
<AssistantControlItems
v-for="item in controlItems"
:key="item.name"
:control-item="item"
/> />
</div> <AssistantAudienceForm
:assistant="assistant"
@submit="handleSubmit"
/>
</section>
<section
v-else-if="activeSection === 'schedule'"
class="flex flex-col gap-6"
>
<SettingsHeader
:heading="t('CAPTAIN.ASSISTANTS.SETTINGS.SCHEDULE.TITLE')"
:description="
t('CAPTAIN.ASSISTANTS.SETTINGS.SCHEDULE.DESCRIPTION')
"
/>
<AssistantScheduleForm
:assistant="assistant"
@submit="handleSubmit"
/>
</section>
</div> </div>
</div> </div>
</template> </template>
@@ -1,11 +1,11 @@
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import { createStore } from 'vuex';
import { useRoute } from 'vue-router';
import InboxChannelsDialog from '../../inbox-setup/InboxChannelsDialog.vue'; import InboxChannelsDialog from '../../inbox-setup/InboxChannelsDialog.vue';
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: key => key }) })); vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: key => key }) }));
vi.mock('dashboard/composables/store', () => ({ vi.mock('vue-router');
useMapGetter: () => ({ value: {} }),
}));
vi.mock('../../inbox-setup/useChannelConnect', () => ({ vi.mock('../../inbox-setup/useChannelConnect', () => ({
useChannelConnect: () => ({ useChannelConnect: () => ({
connectViaOAuth: vi.fn(), connectViaOAuth: vi.fn(),
@@ -13,10 +13,30 @@ vi.mock('../../inbox-setup/useChannelConnect', () => ({
}), }),
})); }));
const store = createStore({
modules: {
globalConfig: {
namespaced: true,
getters: {
get: () => ({}),
isOnChatwootCloud: () => false,
},
},
accounts: {
namespaced: true,
getters: {
getAccount: () => () => ({ id: 1, features: {} }),
isFeatureEnabledonAccount: () => () => false,
},
},
},
});
const mountDialog = () => const mountDialog = () =>
mount(InboxChannelsDialog, { mount(InboxChannelsDialog, {
props: { inboxes: [] }, props: { inboxes: [] },
global: { global: {
plugins: [store],
stubs: { stubs: {
Dialog: { Dialog: {
template: '<div><slot /></div>', template: '<div><slot /></div>',
@@ -31,6 +51,10 @@ const mountDialog = () =>
}); });
describe('InboxChannelsDialog Facebook gating', () => { describe('InboxChannelsDialog Facebook gating', () => {
beforeEach(() => {
useRoute.mockReturnValue({ params: { accountId: '1' } });
});
afterEach(() => { afterEach(() => {
delete window.chatwootConfig; delete window.chatwootConfig;
}); });
@@ -104,7 +104,7 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
:product_name, :feature_faq, :feature_memory, :feature_citation, :product_name, :feature_faq, :feature_memory, :feature_citation,
:feature_contact_attributes, :feature_contact_attributes,
:welcome_message, :handoff_message, :resolution_message, :welcome_message, :handoff_message, :resolution_message,
:instructions, :temperature :instructions, :temperature, :response_window
]) ])
# Handle array parameters separately to allow partial updates # Handle array parameters separately to allow partial updates
@@ -112,9 +112,21 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
permitted[:guardrails] = params[:assistant][:guardrails] if params[:assistant].key?(:guardrails) permitted[:guardrails] = params[:assistant][:guardrails] if params[:assistant].key?(:guardrails)
permit_audience_config(permitted)
permitted permitted
end end
# The audience is a recursive condition tree that strong params can't whitelist by shape;
# pass it through raw and let Captain::AudienceValidator enforce validity.
def permit_audience_config(permitted)
config = params[:assistant][:config]
return unless config.try(:key?, :audience)
audience = config[:audience]
permitted[:config][:audience] = audience.respond_to?(:permit!) ? audience.permit!.to_h : audience
end
def playground_params def playground_params
params.require(:assistant).permit(:message_content, message_history: [:role, :content, :agent_name]) params.require(:assistant).permit(:message_content, message_history: [:role, :content, :agent_name])
end end
+32 -1
View File
@@ -40,11 +40,15 @@ class Captain::Assistant < ApplicationRecord
has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async
has_many :agent_sessions, class_name: 'Captain::AgentSession', dependent: :destroy_async has_many :agent_sessions, class_name: 'Captain::AgentSession', dependent: :destroy_async
store_accessor :config, :temperature, :feature_faq, :feature_memory, :feature_contact_attributes, :product_name store_accessor :config, :temperature, :feature_faq, :feature_memory, :feature_contact_attributes, :product_name, :response_window
RESPONSE_WINDOWS = %w[always business_hours outside_business_hours].freeze
validates :name, presence: true validates :name, presence: true
validates :description, presence: true, length: { maximum: DESCRIPTION_LENGTH_LIMIT } validates :description, presence: true, length: { maximum: DESCRIPTION_LENGTH_LIMIT }
validates :account_id, presence: true validates :account_id, presence: true
validates_with Captain::AudienceValidator
validate :validate_response_window
scope :ordered, -> { order(created_at: :desc) } scope :ordered, -> { order(created_at: :desc) }
@@ -54,6 +58,26 @@ class Captain::Assistant < ApplicationRecord
name name
end end
def engages?(contact, conversation)
responds_to_audience?(contact, conversation) && available_now?(conversation)
end
def responds_to_audience?(contact, conversation)
return true if config['audience'].blank?
Captain::AudienceMatcher.new(config['audience']).matches?(contact, conversation)
end
def available_now?(conversation)
response_window = config['response_window']
return true if response_window.blank? || response_window == 'always'
inbox = conversation.inbox
return true unless inbox.working_hours_enabled?
response_window == 'business_hours' ? !inbox.out_of_office? : inbox.out_of_office?
end
def available_agent_tools def available_agent_tools
tools = self.class.built_in_agent_tools.dup tools = self.class.built_in_agent_tools.dup
@@ -91,6 +115,13 @@ class Captain::Assistant < ApplicationRecord
private private
def validate_response_window
response_window = config['response_window']
return if response_window.blank?
errors.add(:config, 'invalid response_window') unless RESPONSE_WINDOWS.include?(response_window)
end
def agent_name def agent_name
name.parameterize(separator: '_') name.parameterize(separator: '_')
end end
@@ -33,6 +33,14 @@ module Enterprise::Conversation
private private
def determine_conversation_status
super
return unless pending?
assistant = inbox.captain_assistant
self.status = :open if assistant.present? && !assistant.engages?(contact, self)
end
def dispatch_captain_inference_event(event_name) def dispatch_captain_inference_event(event_name)
dispatcher_dispatch(event_name) dispatcher_dispatch(event_name)
end end
@@ -15,6 +15,16 @@ module Enterprise::Message
private private
def reopen_resolved_conversation
assistant = conversation.inbox.captain_assistant
return super if assistant.blank?
return conversation.open! unless assistant.engages?(conversation.contact, conversation)
super
end
def mark_pending_conversation_as_open_for_human_response def mark_pending_conversation_as_open_for_human_response
return unless captain_pending_conversation? return unless captain_pending_conversation?
return unless human_response? return unless human_response?
@@ -0,0 +1,140 @@
# Evaluates an assistant's audience condition tree in-memory against the conversation's contact.
# A node is either a group ({ operator:, conditions: [...] }) or a leaf
# ({ attribute_key:, filter_operator:, values: [...] }). Operator semantics mirror
# Contacts::FilterService so audiences match the same contacts as segments.
class Captain::AudienceMatcher
CONTACT_STANDARD = %w[name email phone_number identifier blocked created_at last_activity_at].freeze
CONTACT_ADDITIONAL = %w[country_code city company_name].freeze
CONVERSATION_ADDITIONAL = %w[browser_language conversation_language].freeze
OPERATORS = %w[equal_to not_equal_to contains does_not_contain is_present is_not_present starts_with
is_greater_than is_less_than days_before].freeze
# Root group -> sub-group -> leaves.
MAX_DEPTH = 3
def initialize(audience)
@root = audience
end
def matches?(contact, conversation)
return true if @root.blank?
@contact = contact
@conversation = conversation
matches_node?(@root)
end
private
def matches_node?(node)
node = node.with_indifferent_access
node.key?(:conditions) ? matches_group?(node) : matches_leaf?(node)
end
def matches_group?(group)
conditions = Array(group[:conditions])
if group[:operator].to_s.casecmp?('or')
conditions.any? { |child| matches_node?(child) }
else
conditions.all? { |child| matches_node?(child) }
end
end
def matches_leaf?(leaf)
key = leaf[:attribute_key]
actual = attribute_value(key)
values = Array(leaf[:values])
case leaf[:filter_operator]
when 'is_present' then actual.present?
when 'is_not_present' then actual.blank?
when 'equal_to' then values.any? { |expected| value_equal?(key, actual, expected) }
when 'not_equal_to' then values.none? { |expected| value_equal?(key, actual, expected) }
else matches_text_or_range?(leaf[:filter_operator], actual, values.first)
end
end
def attribute_value(key)
case key
when *CONTACT_STANDARD then @contact[key]
when *CONTACT_ADDITIONAL then @contact.additional_attributes[key]
when 'labels' then @contact.label_list
when *CONVERSATION_ADDITIONAL then @conversation.additional_attributes[key]
when 'hmac_verified' then hmac_verified?
else @contact.custom_attributes[key]
end
end
def hmac_verified?
@conversation.contact_inbox&.hmac_verified || false
end
# labels is a has-tag check, booleans cast the expected string, phone numbers
# ignore the "+" prefix, and text compares case-insensitively.
def value_equal?(key, actual, expected)
return Array(actual).include?(expected) if key == 'labels'
return ActiveModel::Type::Boolean.new.cast(expected) == (actual == true) if boolean_condition?(actual, expected)
normalize(key, actual) == normalize(key, expected)
end
# An unset checkbox attribute counts as false; the expected value identifies
# the condition as boolean when the attribute is missing.
def boolean_condition?(actual, expected)
[true, false].include?(actual) || (actual.nil? && %w[true false].include?(expected.to_s))
end
def normalize(key, value)
return value if value.nil?
return "+#{value.to_s.delete('+')}" if key == 'phone_number'
value.is_a?(String) ? value.downcase : value
end
def matches_text_or_range?(operator, actual, expected)
case operator
when 'contains' then actual.to_s.downcase.include?(expected.to_s.downcase)
when 'does_not_contain' then actual.to_s.downcase.exclude?(expected.to_s.downcase)
when 'starts_with' then actual.to_s.downcase.start_with?(expected.to_s.downcase)
when 'is_greater_than' then compare(actual, expected) == 1
when 'is_less_than' then compare(actual, expected) == -1
when 'days_before' then older_than_days?(actual, expected)
else false
end
end
# -1/0/1 like <=>, or nil when blank or unparseable (never matches).
def compare(actual, expected)
return nil if actual.blank?
actual = Time.zone.parse(actual) if iso_date_string?(actual)
if actual.is_a?(Date) || actual.acts_like?(:time)
actual.to_time <=> Time.zone.parse(expected.to_s)
else
BigDecimal(actual.to_s) <=> BigDecimal(expected.to_s)
end
rescue ArgumentError, TypeError
nil
end
# Custom date attributes store ISO strings in jsonb; treat them as dates the way
# Contacts::FilterService does (it casts them in SQL).
def iso_date_string?(value)
value.is_a?(String) && Date.iso8601(value).present?
rescue ArgumentError
false
end
def older_than_days?(actual, days)
date = to_date(actual)
date.present? && date < Time.zone.today - days.to_i.days
end
def to_date(value)
return value.to_date if value.respond_to?(:to_date)
Date.parse(value.to_s)
rescue ArgumentError, TypeError
nil
end
end
@@ -50,7 +50,7 @@ module Enterprise::MessageTemplates::HookExecutionService
end end
def should_process_captain_response? def should_process_captain_response?
conversation.pending? && message.incoming? && inbox.captain_assistant.present? conversation.pending? && message.incoming? && captain_assistant_configured?
end end
def perform_handoff def perform_handoff
@@ -76,6 +76,10 @@ module Enterprise::MessageTemplates::HookExecutionService
end end
def captain_handling_conversation? def captain_handling_conversation?
conversation.pending? && inbox.respond_to?(:captain_assistant) && inbox.captain_assistant.present? conversation.pending? && captain_assistant_configured?
end
def captain_assistant_configured?
inbox.captain_assistant.present?
end end
end end
@@ -0,0 +1,29 @@
class Captain::AudienceValidator < ActiveModel::Validator
def validate(record)
audience = record.config['audience']
return if audience.blank?
record.errors.add(:config, 'audience must be a valid condition tree') unless valid_node?(audience, 1)
end
private
def valid_node?(node, depth)
return false unless node.is_a?(Hash) && depth <= Captain::AudienceMatcher::MAX_DEPTH
node = node.with_indifferent_access
return valid_group?(node, depth) if node.key?(:conditions)
valid_leaf?(node)
end
def valid_group?(node, depth)
node[:conditions].is_a?(Array) &&
node[:conditions].present? &&
node[:conditions].all? { |child| valid_node?(child, depth + 1) }
end
def valid_leaf?(node)
node[:attribute_key].present? && Captain::AudienceMatcher::OPERATORS.include?(node[:filter_operator])
end
end
@@ -216,6 +216,29 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
expect(json_response[:config][:feature_citation]).to be(false) expect(json_response[:config][:feature_citation]).to be(false)
end end
it 'persists the nested audience condition tree' do
audience = {
operator: 'and',
conditions: [
{ attribute_key: 'country_code', filter_operator: 'equal_to', values: ['US'] },
{ operator: 'or', conditions: [
{ attribute_key: 'plan_tier', filter_operator: 'equal_to', values: ['paid'] }
] }
]
}
patch "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}",
params: { assistant: { config: { audience: audience } } },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
stored = assistant.reload.config['audience']
expect(stored['operator']).to eq('and')
expect(stored['conditions'].first['attribute_key']).to eq('country_code')
expect(stored['conditions'].last['conditions'].first['values']).to eq(['paid'])
end
end end
end end
@@ -1,10 +1,162 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe Captain::Assistant do RSpec.describe Captain::Assistant, type: :model do
describe '#agent_tools' do let(:account) { create(:account) }
let(:account) { create(:account) } let(:assistant) { create(:captain_assistant, account: account) }
let(:assistant) { create(:captain_assistant, account: account) } let(:contact) { create(:contact, account: account, additional_attributes: { 'country_code' => 'US' }) }
let(:conversation) { create(:conversation, account: account, contact: contact) }
describe '#responds_to_audience?' do
it 'returns true when no audience is configured' do
expect(assistant.responds_to_audience?(contact, conversation)).to be(true)
end
it 'returns true when the contact matches the audience' do
assistant.update!(config: assistant.config.merge('audience' => {
'attribute_key' => 'country_code', 'filter_operator' => 'equal_to', 'values' => ['US']
}))
expect(assistant.responds_to_audience?(contact, conversation)).to be(true)
end
it 'returns false when the contact does not match the audience' do
assistant.update!(config: assistant.config.merge('audience' => {
'attribute_key' => 'country_code', 'filter_operator' => 'equal_to', 'values' => ['CA']
}))
expect(assistant.responds_to_audience?(contact, conversation)).to be(false)
end
end
describe '#available_now?' do
let(:inbox) { create(:inbox, account: account) }
let(:scheduled_conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
it 'is available when the window is blank or always' do
expect(assistant.available_now?(scheduled_conversation)).to be(true)
assistant.config['response_window'] = 'always'
expect(assistant.available_now?(scheduled_conversation)).to be(true)
end
it 'is available regardless when the inbox has no business hours configured' do
inbox.update!(working_hours_enabled: false)
assistant.config['response_window'] = 'business_hours'
expect(assistant.available_now?(scheduled_conversation)).to be(true)
end
context 'when the inbox has business hours enabled' do
before { inbox.update!(working_hours_enabled: true) }
it 'business_hours matches only when the inbox is open' do
assistant.config['response_window'] = 'business_hours'
allow(scheduled_conversation.inbox).to receive(:out_of_office?).and_return(false)
expect(assistant.available_now?(scheduled_conversation)).to be(true)
allow(scheduled_conversation.inbox).to receive(:out_of_office?).and_return(true)
expect(assistant.available_now?(scheduled_conversation)).to be(false)
end
it 'outside_business_hours matches only when the inbox is closed' do
assistant.config['response_window'] = 'outside_business_hours'
allow(scheduled_conversation.inbox).to receive(:out_of_office?).and_return(true)
expect(assistant.available_now?(scheduled_conversation)).to be(true)
allow(scheduled_conversation.inbox).to receive(:out_of_office?).and_return(false)
expect(assistant.available_now?(scheduled_conversation)).to be(false)
end
end
end
describe 'response_window validation' do
it 'accepts a blank response_window' do
assistant.config['response_window'] = nil
expect(assistant).to be_valid
end
it 'accepts the known windows' do
described_class::RESPONSE_WINDOWS.each do |window|
assistant.config['response_window'] = window
expect(assistant).to be_valid
end
end
it 'rejects an unknown window' do
assistant.config['response_window'] = 'weekends'
expect(assistant).not_to be_valid
expect(assistant.errors[:config]).to include('invalid response_window')
end
end
describe 'audience validation' do
let(:leaf) { { 'attribute_key' => 'country_code', 'filter_operator' => 'equal_to', 'values' => ['US'] } }
it 'accepts a blank audience' do
assistant.config['audience'] = nil
expect(assistant).to be_valid
end
it 'accepts a single leaf' do
assistant.config['audience'] = leaf
expect(assistant).to be_valid
end
it 'accepts symbol keys' do
assistant.config['audience'] = { attribute_key: 'country_code', filter_operator: 'equal_to', values: ['US'] }
expect(assistant).to be_valid
end
it 'accepts a well-formed nested tree' do
assistant.config['audience'] = {
'operator' => 'and',
'conditions' => [
{ 'operator' => 'or', 'conditions' => [leaf] },
leaf
]
}
expect(assistant).to be_valid
end
it 'rejects a node that is not a hash' do
assistant.config['audience'] = ['not-a-node']
expect(assistant).not_to be_valid
expect(assistant.errors[:config]).to include('audience must be a valid condition tree')
end
it 'rejects an unknown operator' do
assistant.config['audience'] = leaf.merge('filter_operator' => 'bogus')
expect(assistant).not_to be_valid
end
it 'rejects a leaf missing attribute_key' do
assistant.config['audience'] = { 'filter_operator' => 'equal_to', 'values' => ['US'] }
expect(assistant).not_to be_valid
end
it 'rejects a group without conditions' do
assistant.config['audience'] = { 'operator' => 'and', 'conditions' => [] }
expect(assistant).not_to be_valid
end
it 'rejects conditions that is not an array' do
assistant.config['audience'] = { 'operator' => 'and', 'conditions' => leaf }
expect(assistant).not_to be_valid
end
it 'rejects a group containing an invalid child' do
assistant.config['audience'] = { 'operator' => 'and', 'conditions' => [leaf, { 'attribute_key' => '' }] }
expect(assistant).not_to be_valid
end
it 'rejects nesting deeper than one level' do
assistant.config['audience'] = {
'operator' => 'and',
'conditions' => [
{ 'operator' => 'or', 'conditions' => [
{ 'operator' => 'and', 'conditions' => [leaf] }
] }
]
}
expect(assistant).not_to be_valid
end
end
describe '#agent_tools' do
it 'includes enabled custom tools from the assistant account' do it 'includes enabled custom tools from the assistant account' do
custom_tool = create(:captain_custom_tool, account: account) custom_tool = create(:captain_custom_tool, account: account)
@@ -0,0 +1,28 @@
require 'rails_helper'
RSpec.describe Conversation, type: :model do
describe 'captain audience routing on create' do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:us_contact) { create(:contact, account: account, additional_attributes: { 'country_code' => 'US' }) }
let(:ca_contact) { create(:contact, account: account, additional_attributes: { 'country_code' => 'CA' }) }
before do
create(:captain_inbox, captain_assistant: assistant, inbox: inbox)
assistant.update!(config: assistant.config.merge('audience' => {
'attribute_key' => 'country_code', 'filter_operator' => 'equal_to', 'values' => ['US']
}))
end
it 'parks an in-audience contact conversation as pending' do
conversation = create(:conversation, account: account, inbox: inbox, contact: us_contact)
expect(conversation.status).to eq('pending')
end
it 'routes an out-of-audience contact conversation to open' do
conversation = create(:conversation, account: account, inbox: inbox, contact: ca_contact)
expect(conversation.status).to eq('open')
end
end
end
@@ -0,0 +1,160 @@
require 'rails_helper'
RSpec.describe Captain::AudienceMatcher do
let(:account) { create(:account) }
let(:contact) do
create(:contact, :with_email, :with_phone_number, account: account,
additional_attributes: { 'country_code' => 'US', 'city' => 'Boston', 'company_name' => 'Acme' },
custom_attributes: { 'plan_tier' => 'paid' })
end
let(:conversation) do
create(:conversation, account: account, contact: contact,
additional_attributes: { 'browser_language' => 'en', 'conversation_language' => 'fr' })
end
def leaf(attribute_key, filter_operator, values = nil)
{ 'attribute_key' => attribute_key, 'filter_operator' => filter_operator, 'values' => Array(values) }
end
def matches?(audience)
described_class.new(audience).matches?(contact, conversation)
end
describe '#matches?' do
it 'returns true when the audience is blank' do
expect(matches?(nil)).to be(true)
expect(matches?({})).to be(true)
end
context 'with contact attribute leaves' do
it 'matches additional_attributes case-insensitively for country_code' do
expect(matches?(leaf('country_code', 'equal_to', 'us'))).to be(true)
expect(matches?(leaf('country_code', 'equal_to', 'ca'))).to be(false)
end
it 'matches custom attributes' do
expect(matches?(leaf('plan_tier', 'equal_to', 'paid'))).to be(true)
expect(matches?(leaf('plan_tier', 'not_equal_to', 'free'))).to be(true)
end
it 'matches checkbox custom attributes' do
contact.update!(custom_attributes: contact.custom_attributes.merge('newsletter_opt_in' => true))
expect(matches?(leaf('newsletter_opt_in', 'equal_to', 'true'))).to be(true)
expect(matches?(leaf('newsletter_opt_in', 'equal_to', 'false'))).to be(false)
end
it 'treats a missing checkbox attribute as false' do
expect(matches?(leaf('newsletter_opt_in', 'equal_to', 'false'))).to be(true)
expect(matches?(leaf('newsletter_opt_in', 'equal_to', 'true'))).to be(false)
expect(matches?(leaf('newsletter_opt_in', 'not_equal_to', 'true'))).to be(true)
end
it 'supports contains / starts_with on text' do
expect(matches?(leaf('email', 'contains', contact.email[2..5]))).to be(true)
expect(matches?(leaf('city', 'starts_with', 'Bos'))).to be(true)
end
it 'normalizes phone numbers' do
expect(matches?(leaf('phone_number', 'equal_to', contact.phone_number.delete('+')))).to be(true)
end
it 'supports presence checks' do
expect(matches?(leaf('email', 'is_present'))).to be(true)
expect(matches?(leaf('identifier', 'is_not_present'))).to be(true)
end
it 'matches blocked boolean' do
contact.update!(blocked: true)
expect(matches?(leaf('blocked', 'equal_to', 'true'))).to be(true)
end
it 'supports days_before on created_at' do
contact.update!(created_at: 40.days.ago)
expect(matches?(leaf('created_at', 'days_before', '30'))).to be(true)
expect(matches?(leaf('created_at', 'days_before', '60'))).to be(false)
end
it 'compares date custom attributes stored as ISO strings' do
contact.update!(custom_attributes: contact.custom_attributes.merge('signed_up_on' => '2024-01-15'))
expect(matches?(leaf('signed_up_on', 'is_greater_than', '2024-01-01'))).to be(true)
expect(matches?(leaf('signed_up_on', 'is_less_than', '2024-01-01'))).to be(false)
expect(matches?(leaf('signed_up_on', 'is_less_than', '2024-02-01'))).to be(true)
end
end
context 'with labels' do
before { contact.update_labels(%w[vip]) }
it 'matches has-tag semantics' do
expect(matches?(leaf('labels', 'equal_to', 'vip'))).to be(true)
expect(matches?(leaf('labels', 'equal_to', 'enterprise'))).to be(false)
end
it 'matches any of multiple selected labels' do
expect(matches?(leaf('labels', 'equal_to', %w[enterprise vip]))).to be(true)
expect(matches?(leaf('labels', 'equal_to', %w[enterprise smb]))).to be(false)
end
it 'not_equal_to rejects contacts carrying any selected label' do
expect(matches?(leaf('labels', 'not_equal_to', %w[enterprise vip]))).to be(false)
expect(matches?(leaf('labels', 'not_equal_to', %w[enterprise smb]))).to be(true)
end
end
context 'with conversation language fields' do
it 'resolves browser_language and conversation_language from the conversation' do
expect(matches?(leaf('browser_language', 'equal_to', 'en'))).to be(true)
expect(matches?(leaf('conversation_language', 'equal_to', 'fr'))).to be(true)
end
end
context 'with the logged-in (hmac_verified) flag' do
it 'matches a verified contact inbox' do
conversation.contact_inbox.update!(hmac_verified: true)
expect(matches?(leaf('hmac_verified', 'equal_to', 'true'))).to be(true)
expect(matches?(leaf('hmac_verified', 'equal_to', 'false'))).to be(false)
end
it 'treats an unverified contact inbox as not logged in' do
conversation.contact_inbox.update!(hmac_verified: false)
expect(matches?(leaf('hmac_verified', 'equal_to', 'false'))).to be(true)
expect(matches?(leaf('hmac_verified', 'equal_to', 'true'))).to be(false)
end
end
context 'with nested groups' do
let(:audience) do
{
'operator' => 'and',
'conditions' => [
leaf('country_code', 'equal_to', 'US'),
{
'operator' => 'or',
'conditions' => [
leaf('created_at', 'days_before', '3650'),
leaf('plan_tier', 'equal_to', 'paid')
]
}
]
}
end
it 'evaluates OR inside AND with correct precedence' do
expect(matches?(audience)).to be(true)
end
it 'fails the AND when the top-level condition is false' do
audience['conditions'][0] = leaf('country_code', 'equal_to', 'CA')
expect(matches?(audience)).to be(false)
end
it 'fails when neither OR branch matches' do
audience['conditions'][1]['conditions'] = [
leaf('created_at', 'days_before', '3650'),
leaf('plan_tier', 'equal_to', 'free')
]
expect(matches?(audience)).to be(false)
end
end
end
end
@@ -126,6 +126,92 @@ RSpec.describe MessageTemplates::HookExecutionService do
end end
end end
context 'when the contact is inside the assistant audience' do
before do
assistant.update!(config: assistant.config.merge('audience' => {
'attribute_key' => 'country_code', 'filter_operator' => 'equal_to', 'values' => ['US']
}))
contact.update!(additional_attributes: { 'country_code' => 'US' })
end
it 'schedules captain response job' do
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
create(:message, conversation: conversation, message_type: :incoming, account: account)
end
end
context 'when the conversation stops matching the audience mid-conversation' do
it 'still schedules captain response job for the pending conversation' do
conversation
assistant.update!(config: assistant.config.merge('audience' => {
'attribute_key' => 'country_code', 'filter_operator' => 'equal_to', 'values' => ['US']
}))
contact.update!(additional_attributes: { 'country_code' => 'CA' })
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
create(:message, conversation: conversation, message_type: :incoming, account: account)
end
end
context 'when the reply schedule stops matching mid-conversation' do
before do
inbox.update!(working_hours_enabled: true)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
open_all_day: true,
closed_all_day: false
)
end
it 'still schedules captain response job when business hours end after captain took the conversation' do
assistant.update!(config: assistant.config.merge('response_window' => 'business_hours'))
conversation
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
open_all_day: false,
closed_all_day: true
)
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
create(:message, conversation: conversation, message_type: :incoming, account: account)
end
it 'still schedules captain response job when business hours begin after captain took the conversation' do
assistant.update!(config: assistant.config.merge('response_window' => 'outside_business_hours'))
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
open_all_day: false,
closed_all_day: true
)
conversation
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
open_all_day: true,
closed_all_day: false
)
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
create(:message, conversation: conversation, message_type: :incoming, account: account)
end
it 'still schedules captain response job when both audience and schedule stop matching' do
assistant.update!(config: assistant.config.merge('response_window' => 'business_hours'))
conversation
assistant.update!(config: assistant.config.merge('audience' => {
'attribute_key' => 'country_code', 'filter_operator' => 'equal_to', 'values' => ['US']
}))
contact.update!(additional_attributes: { 'country_code' => 'CA' })
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
open_all_day: false,
closed_all_day: true
)
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
create(:message, conversation: conversation, message_type: :incoming, account: account)
end
end
context 'when message is outgoing' do context 'when message is outgoing' do
it 'does not schedule captain response job' do it 'does not schedule captain response job' do
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later) expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)