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.
This commit is contained in:
Pranav
2026-06-24 07:19:52 -07:00
parent e86222034e
commit 39606f3944
21 changed files with 1345 additions and 84 deletions
@@ -0,0 +1,147 @@
<script setup>
import { ref, watch, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.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 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 = () => {
if (!groupRef.value.validate()) return;
const audience = root.value.conditions.length
? serializeNode(root.value)
: null;
emit('submit', {
config: {
...props.assistant.config,
audience,
},
});
};
watch(
() => props.assistant,
newAssistant => {
if (newAssistant) root.value = hydrateRoot(newAssistant.config?.audience);
},
{ immediate: true }
);
</script>
<template>
<div class="flex flex-col gap-6">
<AudienceGroup
ref="groupRef"
v-model="root"
is-root
:filter-types="filterTypes"
/>
<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,147 @@
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';
// Icons for the standard contact attributes, keyed by attribute key.
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',
browser_language: 'i-lucide-globe',
conversation_language: 'i-lucide-languages',
};
// Icons for custom attributes, keyed by the attribute's display type.
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';
/**
* Filter types for a Captain assistant audience: contact attributes (reused from the contact
* segment builder) plus the two conversation language fields. Each option carries an icon, and the
* options are split into grouped sections (contact / conversation / custom) via disabled headers,
* which FilterSelect renders as non-clickable section titles.
*
* @returns {{ filterTypes: import('vue').ComputedRef<Array> }}
*/
export function useAudienceFilterTypes() {
const { t } = useI18n();
const { filterTypes: contactFilterTypes } = useContactFilterContext();
const { equalityOperators } = useOperators();
const contactAttributes = useMapGetter('attributes/getContactAttributes');
// Map custom attribute key -> display type, so each custom option gets a type-based icon.
const customTypeByKey = computed(() =>
(contactAttributes.value || []).reduce((acc, attr) => {
acc[attr.attributeKey] = attr.attributeDisplayType;
return acc;
}, {})
);
// Conversation-level attributes: the logged-in (HMAC verified) flag and the language fields.
// Languages use a searchable dropdown (same option list as automation rules), not free text.
const conversationFilterTypes = computed(() => [
{
attributeKey: 'hmac_verified',
value: 'hmac_verified',
attributeName: t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.LOGGED_IN'),
label: t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.LOGGED_IN'),
icon: 'i-lucide-user-check',
inputType: 'searchSelect',
options: [
{
id: 'true',
name: t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.LOGGED_IN_TRUE'),
},
{
id: 'false',
name: t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.LOGGED_IN_FALSE'),
},
],
dataType: 'text',
filterOperators: equalityOperators.value,
attributeModel: 'additional',
},
...['browser_language', 'conversation_language'].map(key => ({
attributeKey: key,
value: key,
attributeName: t(`CAPTAIN.ASSISTANTS.FORM.AUDIENCE.${key.toUpperCase()}`),
label: t(`CAPTAIN.ASSISTANTS.FORM.AUDIENCE.${key.toUpperCase()}`),
icon: STANDARD_ICONS[key],
inputType: 'searchSelect',
options: languages,
dataType: 'text',
filterOperators: equalityOperators.value,
attributeModel: 'additional',
})),
]);
const header = (id, label) => ({
value: `__group_${id}`,
label,
disabled: true,
});
const filterTypes = computed(() => {
const standard = [];
const custom = [];
contactFilterTypes.value.forEach(type => {
if (type.attributeModel === 'customAttributes') {
// Only contact-model custom attributes belong here; never conversation/company ones.
if (!(type.attributeKey in customTypeByKey.value)) return;
const displayType = customTypeByKey.value[type.attributeKey];
custom.push({
...type,
icon: CUSTOM_TYPE_ICONS[displayType] || DEFAULT_ICON,
});
} else {
standard.push({
...type,
icon: STANDARD_ICONS[type.attributeKey] || DEFAULT_ICON,
});
}
});
return [
header('contact', t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.GROUP_CONTACT')),
...standard,
header(
'conversation',
t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.GROUP_CONVERSATION')
),
...conversationFilterTypes.value,
...(custom.length
? [
header(
'custom',
t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.GROUP_CUSTOM')
),
...custom,
]
: []),
];
});
return { filterTypes };
}
@@ -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"
/>
<FilterSelect
@@ -165,16 +165,6 @@ export function useContactFilterContext() {
filterOperators: dateOperators.value,
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,
value: CONTACT_ATTRIBUTES.BLOCKED,
@@ -1,5 +1,6 @@
<script setup>
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,21 @@ const selected = defineModel({
required: true,
});
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 +83,12 @@ const dropdownPosition = computed(() => {
const updateSelected = newValue => {
selected.value = newValue;
searchQuery.value = '';
};
const handleTriggerClick = toggle => {
searchQuery.value = '';
toggle();
};
</script>
@@ -81,7 +105,7 @@ const updateSelected = newValue => {
:icon="iconToRender"
:trailing-icon="selectedOption.icon ? false : true"
:label="label || (hideLabel ? null : selectedOption.label)"
@click="toggle"
@click="handleTriggerClick(toggle)"
/>
</slot>
</template>
@@ -91,8 +115,17 @@ const updateSelected = newValue => {
:class="dropdownPosition"
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">
<template v-for="option in options" :key="option.value">
<template v-for="option in filteredOptions" :key="option.value">
<li
v-if="option.disabled"
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)"
/>
</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>
</DropdownBody>
</DropdownContainer>
@@ -19,6 +19,8 @@
"OR": "OR"
},
"INPUT_PLACEHOLDER": "Enter value",
"SEARCH_PLACEHOLDER": "Search…",
"NO_RESULTS": "No matches",
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": {
@@ -530,6 +530,39 @@
"ALLOW_MEMORIES": "Capture key details as memories from customer interactions.",
"ALLOW_CITATIONS": "Include source citations in responses",
"ALLOW_CONTACT_ATTRIBUTES": "Allow access to contact information"
},
"AUDIENCE": {
"ADD_CONDITION": "Add condition",
"ADD_GROUP": "Add condition group",
"EMPTY_TITLE": "No conditions yet",
"EMPTY_BODY": "By default, Captain responds to every conversation in connected inboxes. 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": {
@@ -548,6 +581,14 @@
"TITLE": "System settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
},
"AUDIENCE": {
"TITLE": "Audience",
"DESCRIPTION": "Limit which customers this assistant responds to. Leave empty to respond to everyone."
},
"SCHEDULE": {
"TITLE": "Schedule",
"DESCRIPTION": "Choose when Captain replies. Outside this window, conversations are handed to your team."
},
"CONTROL_ITEMS": {
"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.",
@@ -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 AssistantBasicSettingsForm from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantBasicSettingsForm.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';
const { t } = useI18n();
@@ -35,29 +36,56 @@ const assistant = computed(() =>
store.getters['captainAssistants/getRecord'](assistantId.value)
);
const controlItems = computed(() => {
return [
const activeSection = ref('basic');
const navItems = computed(() => {
const items = [
{
name: t(
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.GUARDRAILS.TITLE'
),
description: t(
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.GUARDRAILS.DESCRIPTION'
),
routeName: 'captain_assistants_guardrails_index',
id: 'basic',
label: t('CAPTAIN.ASSISTANTS.SETTINGS.BASIC_SETTINGS.TITLE'),
},
{
name: t(
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.RESPONSE_GUIDELINES.TITLE'
),
description: t(
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.RESPONSE_GUIDELINES.DESCRIPTION'
),
routeName: 'captain_assistants_guidelines_index',
id: 'system',
label: t('CAPTAIN.ASSISTANTS.SETTINGS.SYSTEM_SETTINGS.TITLE'),
},
{ 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 => {
try {
await store.dispatch('captainAssistants/update', {
@@ -107,18 +135,30 @@ const handleDeleteSuccess = () => {
:is-fetching="isFetching"
:show-pagination-footer="false"
:show-know-more="false"
:class="{
'[&>header>div]:max-w-[80rem] [&>main>div]:max-w-[80rem]':
isCaptainV2Enabled,
}"
>
<template #body>
<div
class="gap-6 lg:gap-16 pb-8"
:class="{ 'grid grid-cols-2': isCaptainV2Enabled }"
>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-6">
<div class="flex gap-8 pb-8">
<nav
class="sticky self-start flex flex-col flex-shrink-0 w-48 gap-1 top-0"
>
<button
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
:heading="t('CAPTAIN.ASSISTANTS.SETTINGS.BASIC_SETTINGS.TITLE')"
:description="
@@ -129,9 +169,35 @@ const handleDeleteSuccess = () => {
:assistant="assistant"
@submit="handleSubmit"
/>
</div>
<span class="h-px w-full bg-n-weak mt-2" />
<div class="flex flex-col gap-6">
<span class="w-full h-px mt-2 bg-n-weak" />
<div class="flex items-end justify-between w-full gap-4">
<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
:heading="t('CAPTAIN.ASSISTANTS.SETTINGS.SYSTEM_SETTINGS.TITLE')"
:description="
@@ -142,45 +208,39 @@ const handleDeleteSuccess = () => {
:assistant="assistant"
@submit="handleSubmit"
/>
</div>
<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-2">
<h6 class="text-n-slate-12 text-base font-medium">
{{ t('CAPTAIN.ASSISTANTS.SETTINGS.DELETE.TITLE') }}
</h6>
<span class="text-n-slate-11 text-sm">
{{ 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>
</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"
</section>
<section
v-else-if="activeSection === 'audience'"
class="flex flex-col gap-6"
>
<SettingsHeader
:heading="t('CAPTAIN.ASSISTANTS.SETTINGS.AUDIENCE.TITLE')"
:description="
t('CAPTAIN.ASSISTANTS.SETTINGS.AUDIENCE.DESCRIPTION')
"
/>
</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>
</template>
@@ -59,7 +59,7 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
:product_name, :feature_faq, :feature_memory, :feature_citation,
:feature_contact_attributes,
:welcome_message, :handoff_message, :resolution_message,
:instructions, :temperature
:instructions, :temperature, :response_window
])
# Handle array parameters separately to allow partial updates
@@ -67,9 +67,22 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
permitted[:guardrails] = params[:assistant][:guardrails] if params[:assistant].key?(:guardrails)
# The audience is a recursive condition tree that strong params can't whitelist by shape;
# route it through separately. Validity is enforced by Captain::Assistant#validate_audience_structure.
permit_audience_config(permitted)
permitted
end
def permit_audience_config(permitted)
config = params[:assistant][:config]
return unless config.respond_to?(:key?) && config.key?(:audience)
audience = config[:audience]
permitted[:config] ||= ActionController::Parameters.new.permit!
permitted[:config][:audience] = audience.respond_to?(:permit!) ? audience.permit!.to_h : audience
end
def playground_params
params.require(:assistant).permit(:message_content, message_history: [:role, :content, :agent_name])
end
+64 -1
View File
@@ -36,11 +36,15 @@ class Captain::Assistant < ApplicationRecord
has_many :copilot_threads, dependent: :destroy_async
has_many :scenarios, class_name: 'Captain::Scenario', 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 :description, presence: true
validates :account_id, presence: true
validate :validate_audience_structure
validate :validate_response_window
scope :ordered, -> { order(created_at: :desc) }
@@ -50,6 +54,32 @@ class Captain::Assistant < ApplicationRecord
name
end
# Whether this assistant should engage the given conversation right now — combines the audience
# filter (who) and the schedule (when).
def engages?(contact, conversation)
responds_to_audience?(contact, conversation) && available_now?(conversation)
end
# Whether this assistant should engage the given contact, based on its audience filter.
# No audience configured => responds to everyone (back-compat).
def responds_to_audience?(contact, conversation)
return true if config['audience'].blank?
Captain::AudienceMatcher.new(config['audience']).matches?(contact, conversation)
end
# Whether the assistant is on duty for this conversation based on the response window.
# Inboxes without business hours configured are always covered (fail open).
def available_now?(conversation)
window = config['response_window']
return true if window.blank? || window == 'always'
inbox = conversation.inbox
return true unless inbox.working_hours_enabled?
window == 'business_hours' ? !inbox.out_of_office? : inbox.out_of_office?
end
def available_agent_tools
tools = self.class.built_in_agent_tools.dup
@@ -87,6 +117,39 @@ class Captain::Assistant < ApplicationRecord
private
def validate_audience_structure
audience = config['audience']
return if audience.blank?
errors.add(:config, 'audience must be a valid condition tree') unless valid_audience_node?(audience, 1)
end
def validate_response_window
window = config['response_window']
return if window.blank?
errors.add(:config, 'invalid response_window') unless RESPONSE_WINDOWS.include?(window)
end
def valid_audience_node?(node, depth)
return false unless node.is_a?(Hash) && depth <= Captain::AudienceMatcher::MAX_DEPTH
node = node.with_indifferent_access
return valid_audience_group?(node, depth) if node.key?(:conditions)
valid_audience_leaf?(node)
end
def valid_audience_group?(node, depth)
node[:conditions].is_a?(Array) &&
node[:conditions].present? &&
node[:conditions].all? { |child| valid_audience_node?(child, depth + 1) }
end
def valid_audience_leaf?(node)
node[:attribute_key].present? && Captain::AudienceMatcher::OPERATORS.include?(node[:filter_operator])
end
def agent_name
name.parameterize(separator: '_')
end
@@ -33,6 +33,25 @@ module Enterprise::Conversation
private
# When a Captain inbox parks new conversations as pending, demote to open (human queue) when the
# assistant won't engage (contact outside audience, or off-schedule) so they aren't stuck waiting
# on a bot that stays silent.
def determine_conversation_status
super
return unless pending?
self.status = :open unless captain_should_engage?
end
# True for non-Captain inboxes (don't interfere) and for Captain inboxes that should engage this
# conversation (audience matches AND on-schedule). False only when a Captain assistant opts out.
def captain_should_engage?
assistant = inbox.captain_assistant
return true if assistant.blank?
assistant.engages?(contact, self)
end
def dispatch_captain_inference_event(event_name)
dispatcher_dispatch(event_name)
end
@@ -15,6 +15,22 @@ module Enterprise::Message
private
# On reopen, a Captain inbox would normally re-pend the conversation for the bot. When the
# assistant won't engage (contact outside audience, or off-schedule), route to the human queue
# (open) instead of pending.
def reopen_resolved_conversation
return conversation.open! if captain_should_not_engage?
super
end
def captain_should_not_engage?
inbox = conversation.inbox
inbox.respond_to?(:captain_assistant) &&
inbox.captain_assistant.present? &&
!inbox.captain_assistant.engages?(conversation.contact, conversation)
end
def mark_pending_conversation_as_open_for_human_response
return unless captain_pending_conversation?
return unless human_response?
@@ -0,0 +1,153 @@
# Evaluates a Captain assistant's audience tree in-memory against the conversation's contact
# (plus the two conversation language fields). Zero DB queries on the hot path.
#
# A node is either:
# - a GROUP: { "operator" => "and"|"or", "conditions" => [node, ...] }
# - a LEAF: { "attribute_key" => "...", "filter_operator" => "...", "values" => [...] }
#
# Operator semantics mirror Chatwoot's FilterService (see app/services/filter_service.rb and
# app/services/contacts/filter_service.rb) so the audience agrees with what the same conditions
# would match in the contact segment UI.
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
# One level of nesting: root group (depth 1) -> sub-group (depth 2) -> leaves (depth 3).
MAX_DEPTH = 3
def initialize(audience)
@root = audience
end
def matches?(contact, conversation)
return true if @root.blank?
evaluate(@root, contact, conversation)
end
private
def evaluate(node, contact, conversation)
node = node.with_indifferent_access
return evaluate_group(node, contact, conversation) if node.key?(:conditions)
evaluate_leaf(node, contact, conversation)
end
def evaluate_group(node, contact, conversation)
results = Array(node[:conditions]).map { |child| evaluate(child, contact, conversation) }
node[:operator].to_s.casecmp?('or') ? results.any? : results.all?
end
def evaluate_leaf(node, contact, conversation)
key = node[:attribute_key]
actual = resolve_value(key, contact, conversation)
apply_operator(node[:filter_operator], key, actual, node[:values])
end
def resolve_value(key, contact, conversation)
case key
when *CONTACT_STANDARD then contact.public_send(key)
when *CONTACT_ADDITIONAL then contact.additional_attributes[key]
when 'labels' then contact.label_list
else resolve_conversation_value(key, contact, conversation)
end
end
def resolve_conversation_value(key, contact, conversation)
case key
when *CONVERSATION_ADDITIONAL then conversation.additional_attributes[key]
when 'hmac_verified' then conversation.contact_inbox&.hmac_verified || false
else contact.custom_attributes[key]
end
end
def apply_operator(operator, key, actual, values)
expected = values.is_a?(Array) ? values.first : values
case operator
when 'equal_to' then value_equal?(key, actual, expected)
when 'not_equal_to' then !value_equal?(key, actual, expected)
when 'is_present' then actual.present?
when 'is_not_present' then actual.blank?
else extended_operator(operator, actual, expected)
end
end
def extended_operator(operator, actual, expected)
case operator
when 'contains' then downcase(actual).include?(downcase(expected))
when 'does_not_contain' then downcase(actual).exclude?(downcase(expected))
when 'starts_with' then downcase(actual).start_with?(downcase(expected))
when 'is_greater_than' then compare(actual, expected, :>)
when 'is_less_than' then compare(actual, expected, :<)
when 'days_before' then date_before?(actual, expected)
else false
end
end
def value_equal?(key, actual, expected)
return Array(actual).include?(expected) if key == 'labels'
return ActiveModel::Type::Boolean.new.cast(expected) == actual if [true, false].include?(actual)
normalize(key, actual) == normalize(key, expected)
end
def normalize(key, value)
return value if value.nil?
case key
when 'phone_number' then "+#{value.to_s.delete('+')}"
when 'country_code' then value.to_s.downcase
else value.is_a?(String) ? value.downcase : value
end
end
def downcase(value)
value.to_s.downcase
end
def compare(actual, expected, operator)
return false if actual.blank?
actual_value, expected_value = coerce_pair(actual, expected)
return false if actual_value.nil? || expected_value.nil?
actual_value.public_send(operator, expected_value)
end
def coerce_pair(actual, expected)
if date_like?(actual)
[actual.to_time, parse_time(expected)]
else
[BigDecimal(actual.to_s), BigDecimal(expected.to_s)]
end
rescue ArgumentError, TypeError
[nil, nil]
end
def date_before?(actual, expected)
actual_date = to_date(actual)
return false if actual_date.nil?
actual_date < (Time.zone.today - expected.to_i.days)
end
def date_like?(value)
value.is_a?(Date) || value.is_a?(Time) || value.is_a?(ActiveSupport::TimeWithZone)
end
def parse_time(value)
Time.zone.parse(value.to_s)
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
def should_process_captain_response?
conversation.pending? && message.incoming? && inbox.captain_assistant.present?
conversation.pending? && message.incoming? && captain_should_engage?
end
def perform_handoff
@@ -76,6 +76,15 @@ module Enterprise::MessageTemplates::HookExecutionService
end
def captain_handling_conversation?
conversation.pending? && inbox.respond_to?(:captain_assistant) && inbox.captain_assistant.present?
conversation.pending? && captain_should_engage?
end
# True only when the inbox has a Captain assistant that should engage this conversation now —
# i.e. the contact is within the audience AND the assistant is on-schedule. Otherwise the
# conversation falls back to normal human handling (greeting/OOO templates fire, Captain silent).
def captain_should_engage?
inbox.respond_to?(:captain_assistant) &&
inbox.captain_assistant.present? &&
inbox.captain_assistant.engages?(conversation.contact, conversation)
end
end
@@ -216,6 +216,29 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
expect(response).to have_http_status(:success)
expect(json_response[:config][:feature_citation]).to be(false)
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
@@ -0,0 +1,115 @@
require 'rails_helper'
RSpec.describe Captain::Assistant, type: :model do
let(:account) { create(: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 the known windows' do
%w[always business_hours outside_business_hours].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
end
end
describe 'audience validation' do
it 'accepts a well-formed nested tree' do
assistant.config['audience'] = {
'operator' => 'and',
'conditions' => [
{ 'attribute_key' => 'country_code', 'filter_operator' => 'equal_to', 'values' => ['US'] }
]
}
expect(assistant).to be_valid
end
it 'rejects an unknown operator' do
assistant.config['audience'] = { 'attribute_key' => 'country_code', 'filter_operator' => 'bogus', '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 nesting deeper than one level' do
assistant.config['audience'] = {
'operator' => 'and',
'conditions' => [
{ 'operator' => 'or', 'conditions' => [
{ 'operator' => 'and', 'conditions' => [
{ 'attribute_key' => 'country_code', 'filter_operator' => 'equal_to', 'values' => ['US'] }
] }
] }
]
}
expect(assistant).not_to be_valid
end
end
end
@@ -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,131 @@
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 '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
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
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,36 @@ RSpec.describe MessageTemplates::HookExecutionService do
end
end
context 'when the contact is outside 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' => 'CA' })
end
it 'does not schedule captain response job' do
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)
create(:message, conversation: conversation, message_type: :incoming, account: account)
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 message is outgoing' do
it 'does not schedule captain response job' do
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)