Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5029b25d5c | ||
|
|
304777e728 | ||
|
|
5e811eab99 | ||
|
|
9749a3dc96 | ||
|
|
8dd0d08322 | ||
|
|
7e88d44fd9 | ||
|
|
fef3190be5 | ||
|
|
3cd3d653a5 | ||
|
|
aae21dfbb8 | ||
|
|
2dec6ef362 | ||
|
|
cba79636cb | ||
|
|
bc79897419 | ||
|
|
71ba10d0fa | ||
|
|
b7ac1c28fb | ||
|
|
0e2d577d43 | ||
|
|
6bbb763d4a | ||
|
|
cfcbe707e3 | ||
|
|
94fe5318b4 | ||
|
|
8def9220a9 | ||
|
|
59a88dae9f | ||
|
|
38fccdd04b | ||
|
|
9b54bc5096 | ||
|
|
39606f3944 |
@@ -1,6 +1,17 @@
|
||||
class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage::DirectUploadsController
|
||||
include DeviseTokenAuth::Concerns::SetUserByToken
|
||||
include RequestExceptionHandler
|
||||
include AccessTokenAuthHelper
|
||||
include EnsureCurrentAccountHelper
|
||||
|
||||
skip_before_action :verify_authenticity_token, if: :authenticate_by_access_token?
|
||||
|
||||
around_action :handle_with_exception
|
||||
before_action :authenticate_access_token!, if: :authenticate_by_access_token?
|
||||
before_action :validate_bot_access_token!, if: :authenticate_by_access_token?
|
||||
before_action :authenticate_user!, unless: :authenticate_by_access_token?
|
||||
before_action :current_account
|
||||
before_action :validate_token_api_access, if: :authenticate_by_access_token?
|
||||
before_action :conversation
|
||||
|
||||
def create
|
||||
@@ -11,6 +22,16 @@ class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage:
|
||||
|
||||
private
|
||||
|
||||
def authenticate_by_access_token?
|
||||
request.headers[:api_access_token].present? || request.headers[:HTTP_API_ACCESS_TOKEN].present?
|
||||
end
|
||||
|
||||
def validate_token_api_access
|
||||
return if Current.account.api_and_webhooks_enabled?
|
||||
|
||||
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id])
|
||||
end
|
||||
|
||||
@@ -14,6 +14,8 @@ module EnsureCurrentAccountHelper
|
||||
account_accessible_for_user?(account)
|
||||
elsif @resource.is_a?(AgentBot)
|
||||
account_accessible_for_bot?(account)
|
||||
else
|
||||
render_unauthorized(I18n.t('errors.account.not_authorized'))
|
||||
end
|
||||
account
|
||||
end
|
||||
@@ -21,7 +23,7 @@ module EnsureCurrentAccountHelper
|
||||
def account_accessible_for_user?(account)
|
||||
@current_account_user = account.account_users.find_by(user_id: current_user.id)
|
||||
Current.account_user = @current_account_user
|
||||
render_unauthorized('You are not authorized to access this account') unless @current_account_user
|
||||
render_unauthorized(I18n.t('errors.account.not_authorized')) unless @current_account_user
|
||||
end
|
||||
|
||||
def account_accessible_for_bot?(account)
|
||||
|
||||
+173
@@ -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>
|
||||
+66
@@ -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>
|
||||
+167
@@ -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>
|
||||
+137
@@ -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 };
|
||||
}
|
||||
+99
@@ -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({
|
||||
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>
|
||||
|
||||
@@ -22,21 +22,11 @@ const props = defineProps({
|
||||
type: [Number, String],
|
||||
default: 0,
|
||||
},
|
||||
manualMigrationRecommended: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['reviewManualMigration']);
|
||||
|
||||
const reauthorizationRequired = computed(() => {
|
||||
return props.inbox.reauthorization_required;
|
||||
});
|
||||
|
||||
const showManualMigrationRecommendation = computed(() => {
|
||||
return props.manualMigrationRecommended && !reauthorizationRequired.value;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -52,14 +42,4 @@ const showManualMigrationRecommendation = computed(() => {
|
||||
>
|
||||
<Icon icon="i-woot-alert" class="size-3 text-n-ruby-9" />
|
||||
</div>
|
||||
<button
|
||||
v-else-if="showManualMigrationRecommendation"
|
||||
v-tooltip.top-end="$t('SIDEBAR.WHATSAPP_MANUAL_MIGRATION')"
|
||||
type="button"
|
||||
:aria-label="$t('SIDEBAR.WHATSAPP_MANUAL_MIGRATION')"
|
||||
class="grid place-content-center size-5 bg-n-blue-5/60 rounded-full hover:bg-n-blue-5 focus-visible:bg-n-blue-5 focus-visible:outline-none"
|
||||
@click.stop.prevent="emit('reviewManualMigration')"
|
||||
>
|
||||
<Icon icon="i-lucide-info" class="size-3 text-n-blue-9" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -6,8 +6,6 @@ import { useKbd } from 'dashboard/composables/utils/useKbd';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
@@ -45,8 +43,6 @@ const emit = defineEmits([
|
||||
]);
|
||||
|
||||
const { accountScopedRoute, isOnChatwootCloud } = useAccount();
|
||||
const { isAdmin } = useAdmin();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const searchShortcut = useKbd([`$mod`, 'k']);
|
||||
const { t } = useI18n();
|
||||
@@ -96,32 +92,6 @@ const hasDataImport = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const hasWhatsAppManualTransfer = computed(() => {
|
||||
return isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
FEATURE_FLAGS.WHATSAPP_MANUAL_TRANSFER
|
||||
);
|
||||
});
|
||||
|
||||
const isWhatsAppManualMigrationRecommended = inbox => {
|
||||
return (
|
||||
isAdmin.value &&
|
||||
hasWhatsAppManualTransfer.value &&
|
||||
inbox.channel_type === 'Channel::Whatsapp' &&
|
||||
inbox.provider === 'whatsapp_cloud' &&
|
||||
inbox.provider_config?.source === 'embedded_signup' &&
|
||||
!inbox.reauthorization_required
|
||||
);
|
||||
};
|
||||
|
||||
const reviewWhatsAppManualMigration = inboxId => {
|
||||
router.push(
|
||||
accountScopedRoute('settings_inbox_show', {
|
||||
inboxId,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
|
||||
if (!currentAccountId) return;
|
||||
|
||||
@@ -488,10 +458,6 @@ const menuItems = computed(() => {
|
||||
active: leafProps.active,
|
||||
inbox,
|
||||
badgeCount: leafProps.badgeCount,
|
||||
manualMigrationRecommended:
|
||||
isWhatsAppManualMigrationRecommended(inbox),
|
||||
onReviewManualMigration: () =>
|
||||
reviewWhatsAppManualMigration(inbox.id),
|
||||
}),
|
||||
})),
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { DirectUpload } from 'activestorage';
|
||||
import { setDirectUploadAuthHeaders } from 'dashboard/helper/directUploadsHelper';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { getMaxUploadSizeByChannel } from '@chatwoot/utils';
|
||||
import {
|
||||
@@ -21,7 +22,6 @@ export const useFileUpload = ({ inbox, attachFile, isPrivateNote = false }) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
|
||||
@@ -78,10 +78,7 @@ export const useFileUpload = ({ inbox, attachFile, isPrivateNote = false }) => {
|
||||
`/api/v1/accounts/${accountId.value}/conversations/${currentChat.value.id}/direct_uploads`,
|
||||
{
|
||||
directUploadWillCreateBlobWithXHR: xhr => {
|
||||
xhr.setRequestHeader(
|
||||
'api_access_token',
|
||||
currentUser.value.access_token
|
||||
);
|
||||
setDirectUploadAuthHeaders(xhr);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import Auth from 'dashboard/api/auth';
|
||||
|
||||
export const setDirectUploadAuthHeaders = xhr => {
|
||||
const {
|
||||
'access-token': accessToken,
|
||||
'token-type': tokenType,
|
||||
client,
|
||||
expiry,
|
||||
uid,
|
||||
} = Auth.getAuthData() || {};
|
||||
|
||||
if (!accessToken) return;
|
||||
|
||||
xhr.setRequestHeader('access-token', accessToken);
|
||||
xhr.setRequestHeader('token-type', tokenType);
|
||||
xhr.setRequestHeader('client', client);
|
||||
xhr.setRequestHeader('expiry', expiry);
|
||||
xhr.setRequestHeader('uid', uid);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { setDirectUploadAuthHeaders } from '../directUploadsHelper';
|
||||
import Auth from 'dashboard/api/auth';
|
||||
|
||||
vi.mock('dashboard/api/auth', () => ({
|
||||
default: { getAuthData: vi.fn() },
|
||||
}));
|
||||
|
||||
describe('setDirectUploadAuthHeaders', () => {
|
||||
const buildXhr = () => ({ setRequestHeader: vi.fn() });
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('sets the five session auth headers from the auth cookie', () => {
|
||||
Auth.getAuthData.mockReturnValue({
|
||||
'access-token': 'token-123',
|
||||
'token-type': 'Bearer',
|
||||
client: 'client-123',
|
||||
expiry: '9999',
|
||||
uid: 'agent@example.com',
|
||||
});
|
||||
const xhr = buildXhr();
|
||||
|
||||
setDirectUploadAuthHeaders(xhr);
|
||||
|
||||
expect(xhr.setRequestHeader).toHaveBeenCalledTimes(5);
|
||||
expect(xhr.setRequestHeader).toHaveBeenCalledWith(
|
||||
'access-token',
|
||||
'token-123'
|
||||
);
|
||||
expect(xhr.setRequestHeader).toHaveBeenCalledWith('token-type', 'Bearer');
|
||||
expect(xhr.setRequestHeader).toHaveBeenCalledWith('client', 'client-123');
|
||||
expect(xhr.setRequestHeader).toHaveBeenCalledWith('expiry', '9999');
|
||||
expect(xhr.setRequestHeader).toHaveBeenCalledWith(
|
||||
'uid',
|
||||
'agent@example.com'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not set any header when there is no auth data', () => {
|
||||
Auth.getAuthData.mockReturnValue(false);
|
||||
const xhr = buildXhr();
|
||||
|
||||
setDirectUploadAuthHeaders(xhr);
|
||||
|
||||
expect(xhr.setRequestHeader).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not set any header when the access token is missing', () => {
|
||||
Auth.getAuthData.mockReturnValue({
|
||||
client: 'client-123',
|
||||
uid: 'agent@example.com',
|
||||
});
|
||||
const xhr = buildXhr();
|
||||
|
||||
setDirectUploadAuthHeaders(xhr);
|
||||
|
||||
expect(xhr.setRequestHeader).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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": {
|
||||
|
||||
@@ -11,9 +11,7 @@
|
||||
"WHATSAPP_REGISTRATION_INCOMPLETE": "Your WhatsApp Business registration isn’t complete. Please check your display name status in Meta Business Manager before reconnecting.",
|
||||
"COMPLETE_REGISTRATION": "Complete Registration",
|
||||
"LIST": {
|
||||
"404": "There are no inboxes attached to this account.",
|
||||
"REAUTHORIZATION_REQUIRED": "Reauthorization required",
|
||||
"MANUAL_SETUP_RECOMMENDED": "Manual setup recommended"
|
||||
"404": "There are no inboxes attached to this account."
|
||||
},
|
||||
"CREATE_FLOW": {
|
||||
"CHANNEL": {
|
||||
@@ -61,7 +59,6 @@
|
||||
"ERROR_AUTH": "There was an error connecting to Instagram, please try again",
|
||||
"NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.",
|
||||
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore.",
|
||||
"RESTRICTED_WARNING": "Instagram inbox creation is temporarily unavailable due to current Instagram platform restrictions. We’ll restore support as soon as possible.",
|
||||
"SETTINGS_RESTRICTED_WARNING": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
|
||||
"STATUS_LINK": "View status update"
|
||||
},
|
||||
|
||||
@@ -609,6 +609,47 @@
|
||||
"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": {
|
||||
"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": {
|
||||
@@ -627,6 +668,14 @@
|
||||
"TITLE": "System settings",
|
||||
"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": {
|
||||
"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.",
|
||||
|
||||
@@ -380,7 +380,6 @@
|
||||
"BETA": "Beta",
|
||||
"REPORTS_OVERVIEW": "Overview",
|
||||
"REAUTHORIZE": "Your inbox connection has expired, please reconnect\n to continue receiving and sending messages",
|
||||
"WHATSAPP_MANUAL_MIGRATION": "Manual setup recommended. Reconnect this WhatsApp inbox with your own Meta app.",
|
||||
"HELP_CENTER": {
|
||||
"TITLE": "Help Center",
|
||||
"ARTICLES": "Articles",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useAlert } from 'dashboard/composables';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { getMaxUploadSizeByChannel } from '@chatwoot/utils';
|
||||
import { DirectUpload } from 'activestorage';
|
||||
import { setDirectUploadAuthHeaders } from 'dashboard/helper/directUploadsHelper';
|
||||
import {
|
||||
DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE,
|
||||
resolveMaximumFileUploadSize,
|
||||
@@ -77,10 +78,7 @@ export default {
|
||||
`/api/v1/accounts/${this.accountId}/conversations/${this.currentChat.id}/direct_uploads`,
|
||||
{
|
||||
directUploadWillCreateBlobWithXHR: xhr => {
|
||||
xhr.setRequestHeader(
|
||||
'api_access_token',
|
||||
this.currentUser.access_token
|
||||
);
|
||||
setDirectUploadAuthHeaders(xhr);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
+128
-68
@@ -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>
|
||||
|
||||
+5
-1
@@ -1,4 +1,6 @@
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
// OAuth/SDK channels need installation-level app credentials to be usable. When
|
||||
// the credential is missing the channel is "not configured" and is hidden from
|
||||
@@ -8,6 +10,7 @@ import { useMapGetter } from 'dashboard/composables/store';
|
||||
export function useChannelConfig() {
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
const installationConfig = window.chatwootConfig || {};
|
||||
|
||||
const CHANNEL_CONFIGURED = {
|
||||
@@ -20,7 +23,8 @@ export function useChannelConfig() {
|
||||
Boolean(installationConfig.whatsappConfigurationId),
|
||||
facebook: () => Boolean(installationConfig.fbAppId),
|
||||
instagram: () =>
|
||||
!isOnChatwootCloud.value && Boolean(installationConfig.instagramAppId),
|
||||
Boolean(installationConfig.instagramAppId) &&
|
||||
isCloudFeatureEnabled(FEATURE_FLAGS.CHANNEL_INSTAGRAM),
|
||||
tiktok: () => Boolean(installationConfig.tiktokAppId),
|
||||
gmail: () => Boolean(installationConfig.googleOAuthClientId),
|
||||
outlook: () => Boolean(globalConfig.value.azureAppId),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
import googleClient from 'dashboard/api/channel/googleClient';
|
||||
@@ -24,17 +23,11 @@ export function useChannelConnect() {
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const connectViaOAuth = async provider => {
|
||||
const client = OAUTH_CLIENTS[provider];
|
||||
if (!client) return;
|
||||
|
||||
if (provider === 'instagram' && isOnChatwootCloud.value) {
|
||||
useAlert(t('INBOX_MGMT.ADD.INSTAGRAM.RESTRICTED_WARNING'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { url },
|
||||
|
||||
+27
-3
@@ -1,11 +1,11 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
import { createStore } from 'vuex';
|
||||
import { useRoute } from 'vue-router';
|
||||
import InboxChannelsDialog from '../../inbox-setup/InboxChannelsDialog.vue';
|
||||
|
||||
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: key => key }) }));
|
||||
vi.mock('dashboard/composables/store', () => ({
|
||||
useMapGetter: () => ({ value: {} }),
|
||||
}));
|
||||
vi.mock('vue-router');
|
||||
vi.mock('../../inbox-setup/useChannelConnect', () => ({
|
||||
useChannelConnect: () => ({
|
||||
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 = () =>
|
||||
mount(InboxChannelsDialog, {
|
||||
props: { inboxes: [] },
|
||||
global: {
|
||||
plugins: [store],
|
||||
stubs: {
|
||||
Dialog: {
|
||||
template: '<div><slot /></div>',
|
||||
@@ -31,6 +51,10 @@ const mountDialog = () =>
|
||||
});
|
||||
|
||||
describe('InboxChannelsDialog Facebook gating', () => {
|
||||
beforeEach(() => {
|
||||
useRoute.mockReturnValue({ params: { accountId: '1' } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.chatwootConfig;
|
||||
});
|
||||
|
||||
+23
-1
@@ -13,6 +13,7 @@ vi.mock('vue-router');
|
||||
// channel_type, social ordering) derived from CHANNEL_LIST.
|
||||
const mountComposable = ({
|
||||
brandInfo,
|
||||
features = { channel_instagram: true },
|
||||
inboxes = [],
|
||||
isOnChatwootCloud = false,
|
||||
} = {}) => {
|
||||
@@ -30,8 +31,11 @@ const mountComposable = ({
|
||||
getters: {
|
||||
getAccount: () => () => ({
|
||||
id: 1,
|
||||
features,
|
||||
custom_attributes: { brand_info: brandInfo },
|
||||
}),
|
||||
isFeatureEnabledonAccount: () => (_accountId, feature) =>
|
||||
Boolean(features[feature]),
|
||||
},
|
||||
},
|
||||
inboxes: {
|
||||
@@ -207,7 +211,7 @@ describe('useDetectedChannels', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('hides Instagram from onboarding on Chatwoot Cloud', () => {
|
||||
it('keeps Instagram available on Chatwoot Cloud when enabled for the account', () => {
|
||||
const { displayedChannels } = mountComposable({
|
||||
isOnChatwootCloud: true,
|
||||
brandInfo: {
|
||||
@@ -218,6 +222,24 @@ describe('useDetectedChannels', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
|
||||
'instagram',
|
||||
'tiktok',
|
||||
]);
|
||||
});
|
||||
|
||||
it('hides Instagram when disabled for the account', () => {
|
||||
const { displayedChannels } = mountComposable({
|
||||
features: { channel_instagram: false },
|
||||
isOnChatwootCloud: true,
|
||||
brandInfo: {
|
||||
socials: [
|
||||
{ type: 'instagram', url: 'https://instagram.com/acme' },
|
||||
{ type: 'tiktok', url: 'https://tiktok.com/@acme' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
|
||||
'tiktok',
|
||||
]);
|
||||
|
||||
@@ -15,9 +15,6 @@ import {
|
||||
import ChannelName from './components/ChannelName.vue';
|
||||
import ChannelIcon from 'next/icon/ChannelIcon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
const getters = useStoreGetters();
|
||||
const store = useStore();
|
||||
@@ -29,27 +26,6 @@ const selectedInbox = ref({});
|
||||
const searchQuery = ref('');
|
||||
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const hasWhatsAppManualTransfer = computed(() => {
|
||||
return isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
FEATURE_FLAGS.WHATSAPP_MANUAL_TRANSFER
|
||||
);
|
||||
});
|
||||
|
||||
const isWhatsAppManualMigrationRecommended = inbox => {
|
||||
return (
|
||||
hasWhatsAppManualTransfer.value &&
|
||||
inbox.channel_type === 'Channel::Whatsapp' &&
|
||||
inbox.provider === 'whatsapp_cloud' &&
|
||||
inbox.provider_config?.source === 'embedded_signup' &&
|
||||
!inbox.reauthorization_required
|
||||
);
|
||||
};
|
||||
|
||||
onActivated(() => {
|
||||
store.dispatch('inboxes/get');
|
||||
@@ -170,48 +146,12 @@ const openDelete = inbox => {
|
||||
<span class="block text-heading-3 text-n-slate-12 capitalize">
|
||||
{{ inbox.name }}
|
||||
</span>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<ChannelName
|
||||
:channel-type="inbox.channel_type"
|
||||
:medium="inbox.medium"
|
||||
:voice-enabled="inbox.voice_enabled"
|
||||
class="text-body-main text-n-slate-11"
|
||||
/>
|
||||
<router-link
|
||||
v-if="inbox.reauthorization_required"
|
||||
:to="{
|
||||
name: 'settings_inbox_show',
|
||||
params: { inboxId: inbox.id },
|
||||
}"
|
||||
>
|
||||
<Label
|
||||
:label="$t('INBOX_MGMT.LIST.REAUTHORIZATION_REQUIRED')"
|
||||
color="ruby"
|
||||
compact
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="i-lucide-triangle-alert" class="size-3.5" />
|
||||
</template>
|
||||
</Label>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-else-if="isWhatsAppManualMigrationRecommended(inbox)"
|
||||
:to="{
|
||||
name: 'settings_inbox_show',
|
||||
params: { inboxId: inbox.id },
|
||||
}"
|
||||
>
|
||||
<Label
|
||||
:label="$t('INBOX_MGMT.LIST.MANUAL_SETUP_RECOMMENDED')"
|
||||
color="blue"
|
||||
compact
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="i-lucide-info" class="size-3.5" />
|
||||
</template>
|
||||
</Label>
|
||||
</router-link>
|
||||
</div>
|
||||
<ChannelName
|
||||
:channel-type="inbox.channel_type"
|
||||
:medium="inbox.medium"
|
||||
:voice-enabled="inbox.voice_enabled"
|
||||
class="text-body-main text-n-slate-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 justify-end">
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import instagramClient from 'dashboard/api/channel/instagramClient';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const hasError = ref(false);
|
||||
const errorStateMessage = ref('');
|
||||
const errorStateDescription = ref('');
|
||||
const isRequestingAuthorization = ref(false);
|
||||
const isInstagramConnectionRestricted = computed(() => {
|
||||
return isOnChatwootCloud.value;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
@@ -76,36 +68,11 @@ const requestAuthorization = async () => {
|
||||
class="text-white !rounded-full !px-6 bg-gradient-to-r from-[#833AB4] via-[#FD1D1D] to-[#FCAF45]"
|
||||
lg
|
||||
icon="i-ri-instagram-line"
|
||||
:disabled="
|
||||
isRequestingAuthorization || isInstagramConnectionRestricted
|
||||
"
|
||||
:disabled="isRequestingAuthorization"
|
||||
:is-loading="isRequestingAuthorization"
|
||||
:label="$t('INBOX_MGMT.ADD.INSTAGRAM.CONTINUE_WITH_INSTAGRAM')"
|
||||
@click="requestAuthorization()"
|
||||
/>
|
||||
<Banner
|
||||
v-if="isInstagramConnectionRestricted"
|
||||
color="amber"
|
||||
class="w-full max-w-2xl mt-6"
|
||||
>
|
||||
<div class="flex items-start gap-3 text-left">
|
||||
<Icon
|
||||
icon="i-lucide-triangle-alert"
|
||||
class="flex-shrink-0 size-4 mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.RESTRICTED_WARNING') }}
|
||||
<a
|
||||
:href="META_RESTRICTION_STATUS_URL"
|
||||
class="link underline"
|
||||
rel="noopener noreferrer nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</Banner>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+1
-2
@@ -7,8 +7,7 @@ import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
const emit = defineEmits(['start']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const WHATSAPP_MANUAL_MIGRATION_GUIDE_URL =
|
||||
'https://www.chatwoot.com/hc/user-guide/articles/1756799850-how-to-setup-a-whats_app-channel-manual-flow';
|
||||
const WHATSAPP_MANUAL_MIGRATION_GUIDE_URL = 'https://chwt.app/migrate-whatsapp';
|
||||
|
||||
const copy = computed(() => ({
|
||||
title: t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.BANNER.TITLE'),
|
||||
|
||||
+1
-2
@@ -23,8 +23,7 @@ const emit = defineEmits(['reconnect']);
|
||||
const { t } = useI18n();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
const WHATSAPP_MANUAL_MIGRATION_GUIDE_URL =
|
||||
'https://www.chatwoot.com/hc/user-guide/articles/1756799850-how-to-setup-a-whats_app-channel-manual-flow';
|
||||
const WHATSAPP_MANUAL_MIGRATION_GUIDE_URL = 'https://chwt.app/migrate-whatsapp';
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const currentStep = ref(0);
|
||||
|
||||
@@ -75,6 +75,7 @@ en:
|
||||
|
||||
errors:
|
||||
account:
|
||||
not_authorized: You are not authorized to access this account
|
||||
reporting_timezone:
|
||||
invalid: is not a valid timezone
|
||||
support_email:
|
||||
|
||||
@@ -104,7 +104,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
|
||||
@@ -112,9 +112,21 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
|
||||
permitted[:guardrails] = params[:assistant][:guardrails] if params[:assistant].key?(:guardrails)
|
||||
|
||||
permit_audience_config(permitted)
|
||||
|
||||
permitted
|
||||
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
|
||||
params.require(:assistant).permit(:message_content, message_history: [:role, :content, :agent_name])
|
||||
end
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
module Captain::Conversation::MessageBuilder
|
||||
private
|
||||
|
||||
def collect_previous_messages
|
||||
@conversation
|
||||
.messages
|
||||
.where(message_type: [:incoming, :outgoing])
|
||||
.where(private: false)
|
||||
.map do |message|
|
||||
message_hash = {
|
||||
content: prepare_multimodal_message_content(message),
|
||||
role: determine_role(message)
|
||||
}
|
||||
|
||||
# Include agent_name if present in additional_attributes
|
||||
message_hash[:agent_name] = message.additional_attributes['agent_name'] if message.additional_attributes&.dig('agent_name').present?
|
||||
|
||||
message_hash
|
||||
end
|
||||
end
|
||||
|
||||
def determine_role(message)
|
||||
message.message_type == 'incoming' ? 'user' : 'assistant'
|
||||
end
|
||||
|
||||
def prepare_multimodal_message_content(message)
|
||||
Captain::OpenAiMessageBuilderService.new(message: message).generate_content
|
||||
end
|
||||
|
||||
def create_messages
|
||||
validate_message_content!(@response['response'])
|
||||
create_outgoing_message(@response['response'], agent_name: @response['agent_name'])
|
||||
end
|
||||
|
||||
def validate_message_content!(content)
|
||||
raise ArgumentError, 'Message content cannot be blank' if content.blank?
|
||||
end
|
||||
|
||||
def create_outgoing_message(message_content, agent_name: nil, preserve_waiting_since: false)
|
||||
additional_attrs = {}
|
||||
additional_attrs[:agent_name] = agent_name if agent_name.present?
|
||||
|
||||
@conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
sender: @assistant,
|
||||
content: message_content,
|
||||
additional_attributes: additional_attrs,
|
||||
preserve_waiting_since: preserve_waiting_since
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,7 @@
|
||||
class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
include Captain::Conversation::V1ActionClassifier
|
||||
include Captain::Conversation::V1FalsePromiseHandler
|
||||
include Captain::Conversation::MessageBuilder
|
||||
|
||||
MAX_MESSAGE_LENGTH = 10_000
|
||||
retry_on ActiveStorage::FileNotFoundError, attempts: 3, wait: 2.seconds
|
||||
@@ -44,9 +45,11 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
end
|
||||
|
||||
def generate_response_with_v2
|
||||
@response = Captain::Assistant::AgentRunnerService.new(assistant: @assistant, conversation: @conversation).generate_response(
|
||||
message_history: collect_previous_messages_with_resolution_markers
|
||||
)
|
||||
runner_service = Captain::Assistant::AgentRunnerService.new(assistant: @assistant, conversation: @conversation)
|
||||
message_history = Captain::Conversation::MessageHistoryBuilderService.new(conversation: @conversation).perform
|
||||
@response = runner_service.generate_response(message_history: message_history)
|
||||
@run_result = runner_service.last_run_result
|
||||
|
||||
process_response
|
||||
end
|
||||
|
||||
@@ -65,6 +68,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
# left is the customer-facing follow-up message.
|
||||
process_v2_handoff
|
||||
end
|
||||
capture_assistant_session(result_message: @handoff_message, credits_consumed: 0.0)
|
||||
elsif v1_handoff_requested?
|
||||
# V1 only signals via the response string — no state has been touched yet. If
|
||||
# the conversation isn't pending anymore, a human took over mid-run; bail out
|
||||
@@ -73,44 +77,16 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
|
||||
process_v1_handoff
|
||||
elsif conversation_pending?
|
||||
message = nil
|
||||
ActiveRecord::Base.transaction do
|
||||
create_messages
|
||||
message = create_messages
|
||||
Rails.logger.info("[CAPTAIN][ResponseBuilderJob] Incrementing response usage for #{account.id}")
|
||||
account.increment_response_usage
|
||||
end
|
||||
capture_assistant_session(result_message: message, credits_consumed: 1.0)
|
||||
end
|
||||
end
|
||||
|
||||
def collect_previous_messages
|
||||
@conversation
|
||||
.messages
|
||||
.where(message_type: [:incoming, :outgoing])
|
||||
.where(private: false)
|
||||
.map do |message|
|
||||
message_hash = {
|
||||
content: prepare_multimodal_message_content(message),
|
||||
role: determine_role(message)
|
||||
}
|
||||
|
||||
# Include agent_name if present in additional_attributes
|
||||
message_hash[:agent_name] = message.additional_attributes['agent_name'] if message.additional_attributes&.dig('agent_name').present?
|
||||
|
||||
message_hash
|
||||
end
|
||||
end
|
||||
|
||||
def collect_previous_messages_with_resolution_markers
|
||||
Captain::Conversation::MessageHistoryBuilderService.new(conversation: @conversation).perform
|
||||
end
|
||||
|
||||
def determine_role(message)
|
||||
message.message_type == 'incoming' ? 'user' : 'assistant'
|
||||
end
|
||||
|
||||
def prepare_multimodal_message_content(message)
|
||||
Captain::OpenAiMessageBuilderService.new(message: message).generate_content
|
||||
end
|
||||
|
||||
def v1_handoff_requested?
|
||||
legacy_v1_handoff_token? || classifier_v1_handoff_requested?
|
||||
end
|
||||
@@ -157,34 +133,18 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
end
|
||||
|
||||
def create_handoff_message(preserve_waiting_since: false)
|
||||
create_outgoing_message(
|
||||
@handoff_message = create_outgoing_message(
|
||||
@assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff'),
|
||||
preserve_waiting_since: preserve_waiting_since
|
||||
)
|
||||
end
|
||||
|
||||
def create_messages
|
||||
validate_message_content!(@response['response'])
|
||||
create_outgoing_message(@response['response'], agent_name: @response['agent_name'])
|
||||
end
|
||||
|
||||
def validate_message_content!(content)
|
||||
raise ArgumentError, 'Message content cannot be blank' if content.blank?
|
||||
end
|
||||
|
||||
def create_outgoing_message(message_content, agent_name: nil, preserve_waiting_since: false)
|
||||
additional_attrs = {}
|
||||
additional_attrs[:agent_name] = agent_name if agent_name.present?
|
||||
|
||||
@conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
sender: @assistant,
|
||||
content: message_content,
|
||||
additional_attributes: additional_attrs,
|
||||
preserve_waiting_since: preserve_waiting_since
|
||||
)
|
||||
# Capture runs outside the delivery transaction and never raises (the service
|
||||
# swallows its own failures): a session-logging bug must never roll back the
|
||||
# customer reply or trigger the top-level handle_error handoff on top of it.
|
||||
def capture_assistant_session(result_message:, credits_consumed:)
|
||||
Captain::Assistant::SessionCaptureService.new(assistant: @assistant, conversation: @conversation, run_result: @run_result,
|
||||
result_message: result_message, credits_consumed: credits_consumed).capture
|
||||
end
|
||||
|
||||
def handle_error(error)
|
||||
|
||||
@@ -40,11 +40,15 @@ class Captain::Assistant < ApplicationRecord
|
||||
has_many :scenarios, class_name: 'Captain::Scenario', 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 :description, presence: true, length: { maximum: DESCRIPTION_LENGTH_LIMIT }
|
||||
validates :account_id, presence: true
|
||||
validates_with Captain::AudienceValidator
|
||||
validate :validate_response_window
|
||||
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
|
||||
@@ -54,6 +58,26 @@ class Captain::Assistant < ApplicationRecord
|
||||
name
|
||||
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
|
||||
tools = self.class.built_in_agent_tools.dup
|
||||
|
||||
@@ -91,6 +115,13 @@ class Captain::Assistant < ApplicationRecord
|
||||
|
||||
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
|
||||
name.parameterize(separator: '_')
|
||||
end
|
||||
|
||||
@@ -31,6 +31,13 @@ module Concerns::Agentable
|
||||
Captain::PromptRenderer.render(template_name, enhanced_context.with_indifferent_access)
|
||||
end
|
||||
|
||||
def agent_model
|
||||
route = Llm::FeatureRouter.resolve(feature: 'assistant', account: account)
|
||||
return route[:model] if route[:source] == :account_override || account&.feature_enabled?('captain_integration_v2')
|
||||
|
||||
installation_model.presence || route[:model]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def agent_name
|
||||
@@ -45,13 +52,6 @@ module Concerns::Agentable
|
||||
[] # Default implementation, override if needed
|
||||
end
|
||||
|
||||
def agent_model
|
||||
route = Llm::FeatureRouter.resolve(feature: 'assistant', account: account)
|
||||
return route[:model] if route[:source] == :account_override || account&.feature_enabled?('captain_integration_v2')
|
||||
|
||||
installation_model.presence || route[:model]
|
||||
end
|
||||
|
||||
def installation_model
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value
|
||||
end
|
||||
|
||||
@@ -33,6 +33,14 @@ module Enterprise::Conversation
|
||||
|
||||
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)
|
||||
dispatcher_dispatch(event_name)
|
||||
end
|
||||
|
||||
@@ -15,6 +15,16 @@ module Enterprise::Message
|
||||
|
||||
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
|
||||
return unless captain_pending_conversation?
|
||||
return unless human_response?
|
||||
|
||||
@@ -5,20 +5,10 @@ class Captain::Assistant::AgentRunnerService
|
||||
include Integrations::LlmInstrumentationConstants
|
||||
include Captain::Assistant::RunnerCallbacksHelper
|
||||
include Captain::Assistant::TracePayloadHelper
|
||||
include Captain::Assistant::RunnerStateHelper
|
||||
|
||||
CONVERSATION_STATE_ATTRIBUTES = %i[
|
||||
id display_id inbox_id contact_id status priority
|
||||
label_list custom_attributes additional_attributes
|
||||
].freeze
|
||||
attr_reader :last_run_result
|
||||
|
||||
CONTACT_STATE_ATTRIBUTES = %i[
|
||||
id name email phone_number identifier contact_type
|
||||
custom_attributes additional_attributes
|
||||
].freeze
|
||||
|
||||
CONTACT_INBOX_STATE_ATTRIBUTES = %i[id hmac_verified].freeze
|
||||
|
||||
CAMPAIGN_STATE_ATTRIBUTES = %i[id title message campaign_type description].freeze
|
||||
def initialize(assistant:, conversation: nil, callbacks: {}, source: nil)
|
||||
@assistant = assistant
|
||||
@conversation = conversation
|
||||
@@ -29,9 +19,9 @@ class Captain::Assistant::AgentRunnerService
|
||||
|
||||
def generate_response(message_history: [])
|
||||
message_to_process, context = run_payload(message_history)
|
||||
result = runner.run(message_to_process, context: context, max_turns: 10)
|
||||
@last_run_result = runner.run(message_to_process, context: context, max_turns: 10)
|
||||
|
||||
process_agent_result(result)
|
||||
process_agent_result(@last_run_result)
|
||||
rescue StandardError => e
|
||||
# In rake/local runs, conversation may not be present, so account is optional here.
|
||||
ChatwootExceptionTracker.new(e, account: @conversation&.account).capture_exception
|
||||
@@ -111,31 +101,6 @@ class Captain::Assistant::AgentRunnerService
|
||||
}
|
||||
end
|
||||
|
||||
def build_state
|
||||
state = {
|
||||
account_id: @assistant.account_id,
|
||||
assistant_id: @assistant.id,
|
||||
assistant_config: @assistant.config,
|
||||
timezone: @conversation&.inbox&.timezone.presence || 'UTC'
|
||||
}
|
||||
state[:source] = @source if @source.present?
|
||||
|
||||
build_conversation_state(state) if @conversation
|
||||
state
|
||||
end
|
||||
|
||||
def build_conversation_state(state)
|
||||
state[:conversation] = slice_attrs(@conversation, CONVERSATION_STATE_ATTRIBUTES)
|
||||
state[:channel_type] = @conversation.inbox&.channel_type
|
||||
state[:contact] = slice_attrs(@conversation.contact, CONTACT_STATE_ATTRIBUTES) if @conversation.contact
|
||||
state[:campaign] = slice_attrs(@conversation.campaign, CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign
|
||||
state[:contact_inbox] = slice_attrs(@conversation.contact_inbox, CONTACT_INBOX_STATE_ATTRIBUTES) if @conversation.contact_inbox
|
||||
end
|
||||
|
||||
def slice_attrs(record, keys)
|
||||
record.attributes.symbolize_keys.slice(*keys)
|
||||
end
|
||||
|
||||
def build_and_wire_agents
|
||||
assistant_agent = @assistant.agent
|
||||
scenario_agents = @assistant.scenarios.enabled.map(&:agent)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
module Captain::Assistant::RunnerStateHelper
|
||||
CONVERSATION_STATE_ATTRIBUTES = %i[
|
||||
id display_id inbox_id contact_id status priority
|
||||
label_list custom_attributes additional_attributes
|
||||
].freeze
|
||||
|
||||
CONTACT_STATE_ATTRIBUTES = %i[
|
||||
id name email phone_number identifier contact_type
|
||||
custom_attributes additional_attributes
|
||||
].freeze
|
||||
|
||||
CONTACT_INBOX_STATE_ATTRIBUTES = %i[id hmac_verified].freeze
|
||||
|
||||
CAMPAIGN_STATE_ATTRIBUTES = %i[id title message campaign_type description].freeze
|
||||
|
||||
private
|
||||
|
||||
def build_state
|
||||
state = {
|
||||
account_id: @assistant.account_id,
|
||||
assistant_id: @assistant.id,
|
||||
assistant_config: @assistant.config,
|
||||
timezone: @conversation&.inbox&.timezone.presence || 'UTC'
|
||||
}
|
||||
state[:source] = @source if @source.present?
|
||||
|
||||
build_conversation_state(state) if @conversation
|
||||
state
|
||||
end
|
||||
|
||||
def build_conversation_state(state)
|
||||
state[:conversation] = slice_attrs(@conversation, CONVERSATION_STATE_ATTRIBUTES)
|
||||
state[:channel_type] = @conversation.inbox&.channel_type
|
||||
state[:contact] = slice_attrs(@conversation.contact, CONTACT_STATE_ATTRIBUTES) if @conversation.contact
|
||||
state[:campaign] = slice_attrs(@conversation.campaign, CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign
|
||||
state[:contact_inbox] = slice_attrs(@conversation.contact_inbox, CONTACT_INBOX_STATE_ATTRIBUTES) if @conversation.contact_inbox
|
||||
end
|
||||
|
||||
def slice_attrs(record, keys)
|
||||
record.attributes.symbolize_keys.slice(*keys)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,64 @@
|
||||
class Captain::Assistant::SessionCaptureService
|
||||
SCENARIO_AGENT_REGEX = /\A#{Captain::Scenario::HANDOFF_KEY_PREFIX}_(\d+)_/
|
||||
|
||||
def initialize(assistant:, conversation:, run_result:, result_message:, credits_consumed:)
|
||||
@assistant = assistant
|
||||
@conversation = conversation
|
||||
@run_result = run_result
|
||||
@result_message = result_message
|
||||
@credits_consumed = credits_consumed
|
||||
end
|
||||
|
||||
def capture
|
||||
# TODO: Capture failed runs once error-session semantics are defined. For now,
|
||||
# only successful runs that produce a customer-facing reply or handoff are recorded.
|
||||
return unless @run_result&.success?
|
||||
|
||||
capture!
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: @assistant.account).capture_exception
|
||||
Rails.logger.error("[CAPTAIN][SessionCaptureService] Capture failed for conversation=#{@conversation.display_id}: #{e.message}")
|
||||
end
|
||||
|
||||
def capture!
|
||||
model = @assistant.agent_model
|
||||
metadata = context.dig(:state, :cw_metadata) || {}
|
||||
|
||||
Captain::AgentSession.create!(
|
||||
assistant: @assistant,
|
||||
session_type: :assistant,
|
||||
subject: @conversation,
|
||||
result: @result_message,
|
||||
llm_model: "#{Llm::Models.provider_for(model)}-#{model}",
|
||||
credits_consumed: @credits_consumed,
|
||||
faq_ids: metadata[:faq_ids] || [],
|
||||
document_ids: metadata[:document_ids] || [],
|
||||
scenario_ids: scenario_ids,
|
||||
run_context: current_turn_history
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def context
|
||||
@run_result.context || {}
|
||||
end
|
||||
|
||||
def scenario_ids
|
||||
ids = current_turn_history.filter_map do |message|
|
||||
next unless message[:role].to_s == 'assistant'
|
||||
|
||||
message[:agent_name].to_s.match(SCENARIO_AGENT_REGEX)&.[](1)&.to_i
|
||||
end.uniq
|
||||
|
||||
ids & @assistant.scenarios.where(id: ids).pluck(:id)
|
||||
end
|
||||
|
||||
# Trim to the current turn: the last user message and everything after it
|
||||
# (assistant replies, tool calls/results, handoff hops).
|
||||
def current_turn_history
|
||||
history = Array(context[:conversation_history])
|
||||
last_user_index = history.rindex { |message| message[:role].to_s == 'user' }
|
||||
last_user_index ? history[last_user_index..] : history
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
def should_process_captain_response?
|
||||
conversation.pending? && message.incoming? && inbox.captain_assistant.present?
|
||||
conversation.pending? && message.incoming? && captain_assistant_configured?
|
||||
end
|
||||
|
||||
def perform_handoff
|
||||
@@ -76,6 +76,10 @@ module Enterprise::MessageTemplates::HookExecutionService
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -2,11 +2,12 @@ class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool
|
||||
description 'Search FAQ responses using semantic similarity to find relevant answers'
|
||||
param :query, type: 'string', desc: 'The question or topic to search for in the FAQ database'
|
||||
|
||||
def perform(_tool_context, query:)
|
||||
def perform(tool_context, query:)
|
||||
log_tool_usage('searching', { query: query })
|
||||
|
||||
# Use existing vector search on approved responses
|
||||
responses = @assistant.responses.approved.search(query).to_a
|
||||
record_retrieved_sources(tool_context, responses)
|
||||
|
||||
if responses.empty?
|
||||
log_tool_usage('no_results', { query: query })
|
||||
@@ -19,6 +20,16 @@ class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool
|
||||
|
||||
private
|
||||
|
||||
def record_retrieved_sources(tool_context, responses)
|
||||
return if responses.empty?
|
||||
|
||||
metadata = tool_context.state[:cw_metadata] ||= {}
|
||||
metadata[:faq_ids] = Array(metadata[:faq_ids]) | responses.map(&:id)
|
||||
|
||||
document_ids = responses.filter_map { |response| response.documentable_id if response.documentable_type == 'Captain::Document' }
|
||||
metadata[:document_ids] = Array(metadata[:document_ids]) | document_ids
|
||||
end
|
||||
|
||||
def format_responses(responses)
|
||||
responses.map { |response| format_response(response) }.join
|
||||
end
|
||||
|
||||
@@ -7,27 +7,120 @@ RSpec.describe '/api/v1/accounts/:account_id/conversations/:conversation_id/dire
|
||||
let(:contact) { create(:contact, account: account, email: nil) }
|
||||
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: web_widget.inbox) }
|
||||
let(:conversation) { create(:conversation, contact: contact, account: account, inbox: web_widget.inbox, contact_inbox: contact_inbox) }
|
||||
let(:blob_params) do
|
||||
{
|
||||
blob: {
|
||||
filename: 'avatar.png',
|
||||
byte_size: '1234',
|
||||
checksum: 'dsjbsdhbfif3874823mnsdbf',
|
||||
content_type: 'image/png'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def create_direct_upload(headers)
|
||||
post api_v1_account_conversation_direct_uploads_path(account_id: account.id, conversation_id: conversation.display_id),
|
||||
params: blob_params,
|
||||
headers: headers,
|
||||
as: :json
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/conversations/:conversation_id/direct_uploads' do
|
||||
context 'when post request is made' do
|
||||
it 'creates attachment message in conversation' do
|
||||
contact
|
||||
context 'when it is an unauthenticated request' do
|
||||
it 'returns unauthorized without any credentials' do
|
||||
create_direct_upload({})
|
||||
|
||||
post api_v1_account_conversation_direct_uploads_path(account_id: account.id, conversation_id: conversation.display_id),
|
||||
params: {
|
||||
blob: {
|
||||
filename: 'avatar.png',
|
||||
byte_size: '1234',
|
||||
checksum: 'dsjbsdhbfif3874823mnsdbf',
|
||||
content_type: 'image/png'
|
||||
}
|
||||
},
|
||||
headers: { api_access_token: agent.access_token.token },
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'returns unauthorized with an empty api_access_token header' do
|
||||
create_direct_upload({ api_access_token: '' })
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'returns unauthorized with an invalid api_access_token header' do
|
||||
create_direct_upload({ api_access_token: 'invalid-token' })
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated request with an api access token' do
|
||||
it 'creates the blob for the direct upload' do
|
||||
create_direct_upload({ api_access_token: agent.access_token.token })
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['content_type']).to eq('image/png')
|
||||
expect(response.parsed_body['content_type']).to eq('image/png')
|
||||
end
|
||||
|
||||
it 'returns unauthorized for an agent of another account' do
|
||||
other_agent = create(:user, account: create(:account), role: :agent)
|
||||
|
||||
create_direct_upload({ api_access_token: other_agent.access_token.token })
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'returns unauthorized for an agent bot token' do
|
||||
agent_bot = create(:agent_bot, account: account)
|
||||
|
||||
create_direct_upload({ api_access_token: agent_bot.access_token.token })
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the account api_and_webhooks feature is disabled' do
|
||||
before do
|
||||
allow(Account).to receive(:find).and_call_original
|
||||
allow(Account).to receive(:find).with(account.id.to_s).and_return(account)
|
||||
allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
|
||||
end
|
||||
|
||||
it 'returns forbidden for a token-authenticated request' do
|
||||
create_direct_upload({ api_access_token: agent.access_token.token })
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
end
|
||||
|
||||
it 'still creates the blob for a session-authenticated request' do
|
||||
create_direct_upload(agent.create_new_auth_token)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['content_type']).to eq('image/png')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated session request' do
|
||||
it 'creates the blob for the direct upload' do
|
||||
create_direct_upload(agent.create_new_auth_token)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['content_type']).to eq('image/png')
|
||||
end
|
||||
|
||||
it 'creates the blob when the serialized api access token is empty' do
|
||||
create_direct_upload(agent.create_new_auth_token.merge('api_access_token' => ''))
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['content_type']).to eq('image/png')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when forgery protection is enabled' do
|
||||
around do |example|
|
||||
original = ActionController::Base.allow_forgery_protection
|
||||
ActionController::Base.allow_forgery_protection = true
|
||||
example.run
|
||||
ActionController::Base.allow_forgery_protection = original
|
||||
end
|
||||
|
||||
it 'creates the blob for a token-authenticated request without a CSRF token' do
|
||||
create_direct_upload({ api_access_token: agent.access_token.token })
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['content_type']).to eq('image/png')
|
||||
end
|
||||
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
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'Hey, welcome to Captain Specs' })
|
||||
allow(Captain::Assistant::AgentRunnerService).to receive(:new).and_return(mock_agent_runner_service)
|
||||
allow(mock_agent_runner_service).to receive(:generate_response).and_return({ 'response' => 'Hey, welcome to Captain V2' })
|
||||
allow(mock_agent_runner_service).to receive(:last_run_result).and_return(nil)
|
||||
allow(Captain::Llm::AssistantActionClassifierService).to receive(:new).and_return(mock_action_classifier_service)
|
||||
allow(mock_action_classifier_service).to receive(:classify).and_return({ 'action' => 'continue' })
|
||||
allow(Captain::Llm::AssistantFalsePromiseService).to receive(:new).and_return(mock_false_promise_service)
|
||||
@@ -72,6 +73,12 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
expect(account.usage_limits[:captain][:responses][:consumed]).to eq(1)
|
||||
end
|
||||
|
||||
it 'does not create a captain session' do
|
||||
expect do
|
||||
described_class.perform_now(conversation, assistant)
|
||||
end.not_to change(Captain::AgentSession, :count)
|
||||
end
|
||||
|
||||
it 'does not run the action classifier when the classifier feature is disabled' do
|
||||
expect(Captain::Llm::AssistantActionClassifierService).not_to receive(:new)
|
||||
|
||||
@@ -490,6 +497,98 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when capturing assistant sessions' do
|
||||
let(:run_context) do
|
||||
{
|
||||
session_id: "#{account.id}_#{conversation.display_id}",
|
||||
current_agent: 'Assistant',
|
||||
turn_count: 1,
|
||||
conversation_history: [
|
||||
{ role: :user, content: 'Hello' },
|
||||
{ role: :assistant, content: 'Hey, welcome to Captain V2', agent_name: 'Assistant' }
|
||||
],
|
||||
state: { cw_metadata: { faq_ids: [7, 9], document_ids: [3] } }
|
||||
}
|
||||
end
|
||||
let(:usage) do
|
||||
Agents::RunContext::Usage.new.tap do |u|
|
||||
u.input_tokens = 100
|
||||
u.output_tokens = 20
|
||||
u.total_tokens = 120
|
||||
end
|
||||
end
|
||||
let(:run_result) { Agents::RunResult.new(output: { 'response' => 'Hey, welcome to Captain V2' }, usage: usage, context: run_context) }
|
||||
|
||||
before do
|
||||
allow(account).to receive(:feature_enabled?).and_return(false)
|
||||
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(true)
|
||||
allow(mock_agent_runner_service).to receive(:last_run_result).and_return(run_result)
|
||||
end
|
||||
|
||||
it 'creates a session for a delivered response' do
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
session = Captain::AgentSession.last
|
||||
expect(session).to have_attributes(
|
||||
account_id: account.id,
|
||||
assistant_id: assistant.id,
|
||||
subject_id: conversation.id,
|
||||
subject_type: 'Conversation',
|
||||
result_id: conversation.messages.outgoing.last.id,
|
||||
result_type: 'Message',
|
||||
llm_model: 'openai-gpt-5.2',
|
||||
credits_consumed: 1.0,
|
||||
faq_ids: [7, 9],
|
||||
document_ids: [3],
|
||||
scenario_ids: [],
|
||||
user_id: nil
|
||||
)
|
||||
expect(session).to be_session_assistant
|
||||
expect(session.run_context.first).to include('role' => 'user', 'content' => 'Hello')
|
||||
end
|
||||
|
||||
it 'creates a zero-credit session when the handoff tool fired' do
|
||||
allow(mock_agent_runner_service).to receive(:generate_response) do
|
||||
conversation.update!(status: :open)
|
||||
{ 'response' => 'Let me connect you', 'handoff_tool_called' => true }
|
||||
end
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
session = Captain::AgentSession.last
|
||||
expect(session.credits_consumed).to eq(0.0)
|
||||
expect(session.result_id).to eq(conversation.messages.outgoing.where(private: false).last.id)
|
||||
expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(0)
|
||||
end
|
||||
|
||||
it 'creates a zero-credit session when the handoff tool fired but failed to commit' do
|
||||
allow(mock_agent_runner_service).to receive(:generate_response).and_return({
|
||||
'response' => 'I tried to hand off',
|
||||
'handoff_tool_called' => true
|
||||
})
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
session = Captain::AgentSession.last
|
||||
expect(session.credits_consumed).to eq(0.0)
|
||||
expect(session.result_id).to eq(conversation.messages.outgoing.where(private: false).last.id)
|
||||
end
|
||||
|
||||
it 'still delivers the reply when session capture fails' do
|
||||
allow(Captain::AgentSession).to receive(:create!).and_raise(StandardError, 'capture failed')
|
||||
allow(ChatwootExceptionTracker).to receive(:new).and_call_original
|
||||
|
||||
expect do
|
||||
described_class.perform_now(conversation, assistant)
|
||||
end.not_to raise_error
|
||||
|
||||
expect(conversation.messages.outgoing.count).to eq(1)
|
||||
expect(conversation.messages.outgoing.last.content).to eq('Hey, welcome to Captain V2')
|
||||
expect(conversation.reload.status).to eq('pending')
|
||||
expect(ChatwootExceptionTracker).to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
# Regression (PR #13417): wrapping create_handoff_message and bot_handoff! in the
|
||||
# same transaction defers the message's after_create_commit until commit, at which
|
||||
# point it clears waiting_since (bot_response). The handoff path must stay outside
|
||||
|
||||
@@ -80,6 +80,21 @@ RSpec.describe Captain::Tools::FaqLookupTool, type: :model do
|
||||
|
||||
tool.perform(tool_context, query: 'password reset')
|
||||
end
|
||||
|
||||
it 'records retrieved faq ids and document ids into Chatwoot metadata' do
|
||||
tool.perform(tool_context, query: 'password reset')
|
||||
|
||||
expect(tool_context.state.dig(:cw_metadata, :faq_ids)).to contain_exactly(response1.id, response2.id)
|
||||
expect(tool_context.state.dig(:cw_metadata, :document_ids)).to contain_exactly(document.id)
|
||||
end
|
||||
|
||||
it 'accumulates unique ids across multiple calls' do
|
||||
tool.perform(tool_context, query: 'password reset')
|
||||
tool.perform(tool_context, query: 'password reset again')
|
||||
|
||||
expect(tool_context.state.dig(:cw_metadata, :faq_ids)).to contain_exactly(response1.id, response2.id)
|
||||
expect(tool_context.state.dig(:cw_metadata, :document_ids)).to contain_exactly(document.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no FAQs found' do
|
||||
@@ -99,6 +114,12 @@ RSpec.describe Captain::Tools::FaqLookupTool, type: :model do
|
||||
|
||||
tool.perform(tool_context, query: 'nonexistent topic')
|
||||
end
|
||||
|
||||
it 'leaves shared state untouched' do
|
||||
tool.perform(tool_context, query: 'nonexistent topic')
|
||||
|
||||
expect(tool_context.state).to eq({})
|
||||
end
|
||||
end
|
||||
|
||||
context 'with blank query' do
|
||||
|
||||
@@ -1,10 +1,162 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Assistant do
|
||||
describe '#agent_tools' do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
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 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
|
||||
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
|
||||
@@ -170,6 +170,12 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
expect(result).to eq({ 'response' => 'Test response', 'agent_name' => nil, 'handoff_tool_called' => false })
|
||||
end
|
||||
|
||||
it 'exposes the raw run result via last_run_result' do
|
||||
service.generate_response(message_history: message_history)
|
||||
|
||||
expect(service.last_run_result).to eq(mock_result)
|
||||
end
|
||||
|
||||
context 'when handoff tool was called during agent execution' do
|
||||
let(:runner_context) { { captain_v2_handoff_tool_called: true } }
|
||||
let(:mock_result) do
|
||||
@@ -246,6 +252,12 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
service.generate_response(message_history: message_history)
|
||||
end
|
||||
|
||||
it 'leaves last_run_result nil' do
|
||||
service.generate_response(message_history: message_history)
|
||||
|
||||
expect(service.last_run_result).to be_nil
|
||||
end
|
||||
|
||||
context 'when conversation is nil' do
|
||||
subject(:service) { described_class.new(assistant: assistant, conversation: nil) }
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Assistant::SessionCaptureService do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:result_message) { create(:message, account: account, conversation: conversation) }
|
||||
|
||||
let(:usage) do
|
||||
Agents::RunContext::Usage.new.tap do |u|
|
||||
u.input_tokens = 120
|
||||
u.output_tokens = 40
|
||||
u.total_tokens = 160
|
||||
end
|
||||
end
|
||||
|
||||
let(:conversation_history) do
|
||||
[
|
||||
{ role: :user, content: 'Hi, my internet is not working' },
|
||||
{ role: :assistant, content: 'Let me check', agent_name: 'Assistant' },
|
||||
{ role: :user, content: 'CUST001' },
|
||||
{ role: :assistant, content: '', agent_name: 'Assistant', tool_calls: [{ 'id' => 'call_1', 'name' => 'faq_lookup' }] },
|
||||
{ role: :tool, content: 'Restart the modem', tool_call_id: 'call_1' },
|
||||
{ role: :assistant, content: 'Please restart your modem', agent_name: 'Assistant' }
|
||||
]
|
||||
end
|
||||
|
||||
let(:run_context) do
|
||||
{
|
||||
session_id: "#{account.id}_#{conversation.display_id}",
|
||||
current_agent: 'Assistant',
|
||||
turn_count: 2,
|
||||
conversation_history: conversation_history,
|
||||
state: { cw_metadata: { faq_ids: [11, 12], document_ids: [5] } }
|
||||
}
|
||||
end
|
||||
|
||||
let(:run_result) { Agents::RunResult.new(output: { 'response' => 'Please restart your modem' }, usage: usage, context: run_context) }
|
||||
|
||||
let(:service) do
|
||||
described_class.new(
|
||||
assistant: assistant,
|
||||
conversation: conversation,
|
||||
run_result: run_result,
|
||||
result_message: result_message,
|
||||
credits_consumed: 1.0
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(assistant).to receive(:agent_model).and_return('gpt-5.2')
|
||||
end
|
||||
|
||||
describe '#capture' do
|
||||
it 'creates the session' do
|
||||
expect { service.capture }.to change(Captain::AgentSession, :count).by(1)
|
||||
end
|
||||
|
||||
it 'does nothing when there is no run result' do
|
||||
service = described_class.new(
|
||||
assistant: assistant, conversation: conversation, run_result: nil,
|
||||
result_message: result_message, credits_consumed: 1.0
|
||||
)
|
||||
|
||||
expect { service.capture }.not_to change(Captain::AgentSession, :count)
|
||||
end
|
||||
|
||||
it 'does nothing when the run failed' do
|
||||
failed_result = Agents::RunResult.new(output: nil, error: StandardError.new('run failed'), context: run_context, usage: usage)
|
||||
service = described_class.new(
|
||||
assistant: assistant, conversation: conversation, run_result: failed_result,
|
||||
result_message: nil, credits_consumed: 0.0
|
||||
)
|
||||
|
||||
expect { service.capture }.not_to change(Captain::AgentSession, :count)
|
||||
end
|
||||
|
||||
it 'reports failures without raising' do
|
||||
allow(Captain::AgentSession).to receive(:create!).and_raise(StandardError, 'capture failed')
|
||||
allow(ChatwootExceptionTracker).to receive(:new).and_call_original
|
||||
|
||||
expect { service.capture }.not_to raise_error
|
||||
expect(ChatwootExceptionTracker).to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#capture!' do
|
||||
it 'creates an assistant session with all attributes' do
|
||||
session = service.capture!
|
||||
|
||||
expect(session).to have_attributes(
|
||||
account_id: account.id,
|
||||
assistant_id: assistant.id,
|
||||
subject_id: conversation.id,
|
||||
subject_type: 'Conversation',
|
||||
result_id: result_message.id,
|
||||
result_type: 'Message',
|
||||
llm_model: 'openai-gpt-5.2',
|
||||
credits_consumed: 1.0,
|
||||
faq_ids: [11, 12],
|
||||
document_ids: [5],
|
||||
scenario_ids: [],
|
||||
user_id: nil
|
||||
)
|
||||
expect(session).to be_session_assistant
|
||||
end
|
||||
|
||||
it 'stores the trimmed current turn in run_context' do
|
||||
history = service.capture!.run_context
|
||||
expect(history.size).to eq(4)
|
||||
expect(history.first).to include('role' => 'user', 'content' => 'CUST001')
|
||||
end
|
||||
|
||||
it 'stores the full history when it contains no user message' do
|
||||
run_context[:conversation_history] = conversation_history.reject { |message| message[:role] == :user }
|
||||
|
||||
history = service.capture!.run_context
|
||||
|
||||
expect(history.size).to eq(4)
|
||||
end
|
||||
|
||||
it 'handles a successful run result without context or usage' do
|
||||
run_result = Agents::RunResult.new(output: { 'response' => 'Hello' })
|
||||
service = described_class.new(
|
||||
assistant: assistant, conversation: conversation, run_result: run_result,
|
||||
result_message: result_message, credits_consumed: 1.0
|
||||
)
|
||||
|
||||
session = service.capture!
|
||||
|
||||
expect(session.result).to eq(result_message)
|
||||
expect(session.faq_ids).to eq([])
|
||||
expect(session.document_ids).to eq([])
|
||||
expect(session.run_context).to eq([])
|
||||
end
|
||||
|
||||
it 'extracts every scenario that authored a message in the current turn' do
|
||||
first_scenario = create(:captain_scenario, assistant: assistant, account: account)
|
||||
second_scenario = create(:captain_scenario, assistant: assistant, account: account)
|
||||
run_context[:conversation_history] = [
|
||||
{ role: :user, content: 'Help with my refund' },
|
||||
{ role: :assistant, content: '', agent_name: first_scenario.handoff_key, tool_calls: [] },
|
||||
{ role: :assistant, content: 'Checking', agent_name: second_scenario.handoff_key },
|
||||
{ role: :assistant, content: 'Done', agent_name: first_scenario.handoff_key }
|
||||
]
|
||||
run_context[:current_agent] = 'Assistant'
|
||||
|
||||
expect(service.capture!.scenario_ids).to eq([first_scenario.id, second_scenario.id])
|
||||
end
|
||||
|
||||
it 'leaves scenario ids empty for the primary assistant agent' do
|
||||
run_context[:current_agent] = 'Assistant'
|
||||
|
||||
expect(service.capture!.scenario_ids).to eq([])
|
||||
end
|
||||
|
||||
it 'does not capture a scenario belonging to another assistant' do
|
||||
scenario = create(:captain_scenario, assistant: create(:captain_assistant, account: account), account: account)
|
||||
run_context[:conversation_history] = [
|
||||
{ role: :user, content: 'Help' },
|
||||
{ role: :assistant, content: 'No', agent_name: scenario.handoff_key }
|
||||
]
|
||||
|
||||
expect(service.capture!.scenario_ids).to eq([])
|
||||
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
|
||||
|
||||
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
|
||||
it 'does not schedule captain response job' do
|
||||
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)
|
||||
|
||||
Reference in New Issue
Block a user