Compare commits

..
Author SHA1 Message Date
Pranav 5029b25d5c fix(captain): keep responding to pending conversations on audience or schedule drift 2026-07-16 20:38:27 -07:00
Pranav 304777e728 Merge branch 'develop' into feat/captain-audience-schedule 2026-07-16 20:21:42 -07:00
5e811eab99 chore(inbox): re-enable Instagram inbox creation (#15042)
Instagram inbox creation is available again on Chatwoot Cloud. Users can
discover and connect Instagram during onboarding or from Add Inbox,
while WhatsApp restrictions and existing-inbox Instagram advisories
remain unchanged.

Closes https://linear.app/chatwoot/issue/CW-7549/enable-instagram

## How to test

1. On Chatwoot Cloud, open Add Inbox and confirm Instagram can be
selected.
2. Confirm **Continue with Instagram** is enabled and starts the OAuth
flow.
3. In onboarding, confirm Instagram is displayed and can start OAuth.
4. Confirm WhatsApp embedded signup remains restricted.

## What changed

- Removed the Cloud-only Instagram filter and OAuth guard from
onboarding.
- Re-enabled the regular Instagram inbox creation action on Cloud.
- Removed the obsolete “Instagram inbox creation is temporarily
unavailable” copy.
- Updated the onboarding expectation for Chatwoot Cloud.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-16 20:28:49 +04:00
Shivam MishraandGitHub 9749a3dc96 feat: capture captain sessions for v2 assistant responses [CW-7485] (#14971)
Records a `Captain::Session` row for every Captain V2 assistant response
delivered in a conversation, so we can show how a response was generated
and report on credit, FAQ, and document usage. Stacked on #14970 (the
`captain_sessions` model).

## What changed

- `FaqLookupTool` now records the retrieved FAQ ids (and their backing
document ids) into the shared run state, accumulated across tool calls.
- `AgentRunnerService` exposes the raw ai-agents run result via
`last_run_result`; the `generate_response` return shape is unchanged, so
the playground path is unaffected.
- New `Captain::Assistant::SessionCaptureService` builds the session:
scenario resolved from the answering agent name, model from
`assistant.agent_model`, token usage plus the trimmed current-turn
conversation history stored in `run_context`.
- `ResponseBuilderJob` captures after delivery: `credits_consumed`
mirrors the actual charge (1.0 for a billed response, 0.0 for handoffs,
where the session points at the customer-facing handoff message).
Capture runs outside the delivery transaction and swallows its own
failures, so a logging bug can never block or roll back a customer
reply.

V1 responses and copilot are out of scope; copilot capture comes next.

## How to test

On an account with `captain_integration_v2` enabled and an inbox
connected to an assistant with approved FAQs, send a customer message on
a pending conversation. After the assistant replies, a
`Captain::Session` row should exist with the conversation as subject,
the reply message as result, the FAQs/documents used, and the run
context for that turn. Asking for a human agent should produce a
zero-credit session pointing at the handoff message.

<img width="2428" height="1058" alt="CleanShot 2026-07-15 at 17 25
40@2x"
src="https://github.com/user-attachments/assets/d8e44923-c17b-494f-8c33-c8fa4219438c"
/>
2026-07-16 18:20:44 +05:30
Shivam MishraandGitHub 8dd0d08322 refactor: align conversation direct uploads with standard account auth (#15039)
Conversation attachment uploads now go through the same authentication
that every other account-scoped API endpoint uses. Agents continue to
attach files exactly as before, and the upload request is now tied to
the agent's dashboard session instead of a separately serialized access
token.

Because the upload request is now authenticated, the dashboard proves
the agent's session directly instead of passing
`currentUser.access_token`. This keeps uploads working alongside the
profile access-token changes in #14973, including on accounts where that
token is serialized as empty.

## What changed

- `Api::V1::Accounts::Conversations::DirectUploadsController` now runs
the standard account auth stack: API access token when the
`api_access_token` header is present, dashboard session
(devise-token-auth) otherwise, with agent-bot tokens rejected.
Previously it inherited `ActiveStorage::DirectUploadsController`
directly and did not run any authentication.
- `EnsureCurrentAccountHelper#ensure_current_account` now returns `401`
when a request has neither an authenticated user nor a bot resource,
instead of continuing. This closes the same gap for any controller that
relies on the helper.
- The dashboard direct-upload paths (`useFileUpload.js` and the legacy
`fileUploadMixin.js`) now attach the agent's session headers to the
upload request via a new `directUploadsHelper.js`, instead of sending
`currentUser.access_token`.

## How to test

1. As a logged-in agent, open a conversation and attach a file. Upload
should succeed as before, on installs with direct uploads enabled.
2. Confirm attachments still work for an agent on an account whose
profile access token is not serialized (e.g. a Cloud plan without
`api_and_webhooks`).
3. Send a `POST` to
`/api/v1/accounts/:account_id/conversations/:conversation_id/direct_uploads`
with no credentials, an empty `api_access_token`, or an invalid token,
and confirm it returns `401`.
4. Confirm a valid agent of the account (via API token or session) gets
`200`, while an agent of a different account gets `401`.
2026-07-16 18:17:29 +05:30
7e88d44fd9 fix(whatsapp): update manual migration guide link (#15040)
Updates the WhatsApp manual migration guide links in the inbox banner
and migration dialog to use `https://chwt.app/migrate-whatsapp`.

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

Schedule: a response window (anytime / during business hours / outside business hours) reusing each inbox's business hours. Off-schedule conversations are handed to the team; inboxes without business hours are always covered.
2026-06-24 07:19:52 -07:00
54 changed files with 2337 additions and 394 deletions
@@ -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)
@@ -0,0 +1,173 @@
<script setup>
import { ref, watch, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
import AudienceGroup from './audience/AudienceGroup.vue';
import { useAudienceFilterTypes } from './audience/useAudienceFilterTypes.js';
const props = defineProps({
assistant: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['submit']);
const { t } = useI18n();
const { filterTypes } = useAudienceFilterTypes();
let uid = 0;
const nextId = () => {
uid += 1;
return `audience-root-${uid}`;
};
const hasConditions = node =>
node && Object.prototype.hasOwnProperty.call(node, 'conditions');
const defaultRoot = () => ({ id: nextId(), operator: 'and', conditions: [] });
const root = ref(defaultRoot());
const mode = ref('everyone');
const findOption = (filterType, value) =>
filterType?.options?.find(option => String(option.id) === String(value));
const hydrateValues = (leaf, filterType) => {
const raw = Array.isArray(leaf.values) ? leaf.values : [leaf.values];
const inputType = filterType?.inputType;
if (inputType === 'multiSelect') {
return raw.map(
value => findOption(filterType, value) ?? { id: value, name: value }
);
}
if (['searchSelect', 'booleanSelect'].includes(inputType)) {
return findOption(filterType, raw[0]) ?? { id: raw[0], name: raw[0] };
}
return raw[0] ?? '';
};
const hydrateNode = node => {
if (hasConditions(node)) {
return {
id: nextId(),
operator: node.operator || 'and',
conditions: (node.conditions || []).map(hydrateNode),
};
}
const filterType = filterTypes.value.find(
type => type.attributeKey === node.attribute_key
);
return {
id: nextId(),
attributeKey: node.attribute_key,
filterOperator: node.filter_operator,
values: hydrateValues(node, filterType),
attributeModel: filterType?.attributeModel || 'standard',
};
};
const hydrateRoot = audience => {
if (!audience) return defaultRoot();
const hydrated = hydrateNode(audience);
return hasConditions(hydrated)
? hydrated
: { id: nextId(), operator: 'and', conditions: [hydrated] };
};
const serializeValues = values => {
if (Array.isArray(values)) {
return values[0]?.id ? values.map(value => value.id) : values;
}
if (values && typeof values === 'object') {
return [values.id];
}
if (values === '' || values === null || values === undefined) {
return [];
}
return [values];
};
const serializeNode = node => {
if (hasConditions(node)) {
return {
operator: node.operator,
conditions: node.conditions.map(serializeNode),
};
}
return {
attribute_key: node.attributeKey,
filter_operator: node.filterOperator,
values: serializeValues(node.values),
};
};
const groupRef = useTemplateRef('groupRef');
const handleSubmit = () => {
const isSpecific = mode.value === 'specific';
if (isSpecific && !groupRef.value.validate()) return;
const audience =
isSpecific && root.value.conditions.length
? serializeNode(root.value)
: null;
emit('submit', {
config: {
...props.assistant.config,
audience,
},
});
};
watch(
() => props.assistant,
newAssistant => {
if (!newAssistant) return;
const audience = newAssistant.config?.audience;
root.value = hydrateRoot(audience);
mode.value = audience ? 'specific' : 'everyone';
},
{ immediate: true }
);
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3">
<RadioCard
id="everyone"
:label="t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.EVERYONE.LABEL')"
:description="t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.EVERYONE.DESC')"
:is-active="mode === 'everyone'"
@select="mode = 'everyone'"
/>
<RadioCard
id="specific"
:label="t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.SPECIFIC.LABEL')"
:description="t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.SPECIFIC.DESC')"
:is-active="mode === 'specific'"
@select="mode = 'specific'"
>
<AudienceGroup
v-if="mode === 'specific'"
ref="groupRef"
v-model="root"
is-root
class="w-full mt-2"
:filter-types="filterTypes"
/>
</RadioCard>
</div>
<div>
<Button
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@click="handleSubmit"
/>
</div>
</div>
</template>
@@ -0,0 +1,66 @@
<script setup>
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
const props = defineProps({
assistant: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['submit']);
const { t } = useI18n();
const OPTIONS = ['always', 'business_hours', 'outside_business_hours'];
const selected = ref('always');
const handleSubmit = () => {
emit('submit', {
config: { ...props.assistant.config, response_window: selected.value },
});
};
watch(
() => props.assistant,
newAssistant => {
if (newAssistant) {
selected.value = newAssistant.config?.response_window || 'always';
}
},
{ immediate: true }
);
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3">
<RadioCard
v-for="option in OPTIONS"
:id="option"
:key="option"
:label="
t(`CAPTAIN.ASSISTANTS.FORM.SCHEDULE.${option.toUpperCase()}.LABEL`)
"
:description="
t(`CAPTAIN.ASSISTANTS.FORM.SCHEDULE.${option.toUpperCase()}.DESC`)
"
:is-active="selected === option"
@select="selected = $event"
/>
</div>
<p class="text-sm text-n-slate-11">
{{ t('CAPTAIN.ASSISTANTS.FORM.SCHEDULE.HINT') }}
</p>
<div>
<Button
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@click="handleSubmit"
/>
</div>
</div>
</template>
@@ -0,0 +1,167 @@
<script setup>
import { useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'next/button/Button.vue';
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
import ConditionRow from 'dashboard/components-next/filter/ConditionRow.vue';
const props = defineProps({
filterTypes: { type: Array, required: true },
depth: { type: Number, default: 0 },
maxDepth: { type: Number, default: 1 },
isRoot: { type: Boolean, default: false },
});
const emit = defineEmits(['remove']);
const node = defineModel({ type: Object, required: true });
const { t } = useI18n();
let uid = 0;
const nextId = () => {
uid += 1;
return `audience-${Date.now()}-${uid}`;
};
const DEFAULT_LEAF = () => ({
id: nextId(),
attributeKey: 'email',
filterOperator: 'contains',
values: '',
attributeModel: 'standard',
});
const operatorOptions = [
{ value: 'and', label: t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.MATCH_ALL') },
{ value: 'or', label: t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.MATCH_ANY') },
];
const isGroup = child =>
Object.prototype.hasOwnProperty.call(child, 'conditions');
const addCondition = () => {
node.value.conditions.push(DEFAULT_LEAF());
};
const addGroup = () => {
node.value.conditions.push({
id: nextId(),
operator: 'and',
conditions: [DEFAULT_LEAF()],
});
};
const removeChild = index => {
node.value.conditions.splice(index, 1);
// A nested group must always hold at least one condition; remove the whole group when emptied.
if (!props.isRoot && node.value.conditions.length === 0) {
emit('remove');
}
};
const leafRefs = useTemplateRef('leafRefs');
const groupRefs = useTemplateRef('groupRefs');
const validate = () => {
const leavesValid = (leafRefs.value ?? []).every(row => row.validate());
const groupsValid = (groupRefs.value ?? []).every(group => group.validate());
return leavesValid && groupsValid;
};
defineExpose({ validate });
</script>
<template>
<div
class="flex flex-col gap-4 group/audience-group"
:class="
isRoot
? 'p-4 border border-n-weak rounded-xl'
: 'p-3 border border-n-weak bg-n-alpha-1 rounded-lg'
"
>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1.5 text-sm text-n-slate-11">
<span>{{ t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.MATCH_PREFIX') }}</span>
<FilterSelect
v-model="node.operator"
variant="faded"
hide-icon
class="text-sm"
:options="operatorOptions"
/>
<span>{{ t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.MATCH_SUFFIX') }}</span>
</div>
<Button
v-if="!isRoot"
sm
ghost
slate
icon="i-lucide-trash"
class="ml-auto flex-shrink-0 opacity-0 transition-opacity group-hover/audience-group:opacity-100"
@click="emit('remove')"
/>
</div>
<ul class="grid gap-3 list-none">
<template v-for="(child, index) in node.conditions" :key="child.id">
<AudienceGroup
v-if="isGroup(child)"
ref="groupRefs"
v-model="node.conditions[index]"
:filter-types="filterTypes"
:depth="depth + 1"
:max-depth="maxDepth"
@remove="removeChild(index)"
/>
<ConditionRow
v-else
ref="leafRefs"
v-model:attribute-key="child.attributeKey"
v-model:filter-operator="child.filterOperator"
v-model:values="child.values"
:filter-types="filterTypes"
:show-query-operator="false"
searchable-attributes
@remove="removeChild(index)"
/>
</template>
</ul>
<div
v-if="isRoot && !node.conditions.length"
class="flex flex-col items-center gap-1.5 px-4 py-8 text-center border border-dashed rounded-lg border-n-weak"
>
<span class="text-2xl i-lucide-users-round text-n-slate-10" />
<p class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.EMPTY_TITLE') }}
</p>
<p class="max-w-md text-sm text-n-slate-11">
{{ t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.EMPTY_BODY') }}
</p>
</div>
<div class="flex gap-2">
<Button
sm
ghost
blue
icon="i-lucide-plus"
class="flex-shrink-0"
:label="t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.ADD_CONDITION')"
@click="addCondition"
/>
<Button
v-if="depth < maxDepth"
sm
ghost
blue
icon="i-lucide-plus"
class="flex-shrink-0"
:label="t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.ADD_GROUP')"
@click="addGroup"
/>
</div>
</div>
</template>
@@ -0,0 +1,137 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useContactFilterContext } from 'dashboard/components-next/filter/contactProvider.js';
import { useOperators } from 'dashboard/components-next/filter/operators.js';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
const STANDARD_ICONS = {
name: 'i-lucide-user',
email: 'i-lucide-mail',
phone_number: 'i-lucide-phone',
identifier: 'i-lucide-fingerprint',
country_code: 'i-lucide-flag',
city: 'i-lucide-map-pin',
company_name: 'i-lucide-building-2',
created_at: 'i-lucide-calendar',
last_activity_at: 'i-lucide-activity',
blocked: 'i-lucide-ban',
labels: 'i-lucide-tags',
hmac_verified: 'i-lucide-user-check',
browser_language: 'i-lucide-globe',
conversation_language: 'i-lucide-languages',
};
const CUSTOM_TYPE_ICONS = {
text: 'i-lucide-type',
number: 'i-lucide-hash',
currency: 'i-lucide-banknote',
percent: 'i-lucide-percent',
link: 'i-lucide-link',
date: 'i-lucide-calendar',
list: 'i-lucide-list',
checkbox: 'i-lucide-square-check',
};
const DEFAULT_ICON = 'i-lucide-tag';
const header = (id, label) => ({
value: `__group_${id}`,
label,
disabled: true,
});
export function useAudienceFilterTypes() {
const { t } = useI18n();
const { filterTypes: contactFilterTypes } = useContactFilterContext();
const { equalityOperators } = useOperators();
const contactAttributes = useMapGetter('attributes/getContactAttributes');
const customTypeByKey = computed(() =>
Object.fromEntries(
(contactAttributes.value || []).map(attr => [
attr.attributeKey,
attr.attributeDisplayType,
])
)
);
const conversationOption = (key, label, options) => ({
attributeKey: key,
value: key,
attributeName: label,
label,
icon: STANDARD_ICONS[key],
inputType: 'searchSelect',
options,
dataType: 'text',
filterOperators: equalityOperators.value,
attributeModel: 'additional',
});
const conversationTypes = computed(() => [
conversationOption(
'hmac_verified',
t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.LOGGED_IN'),
[
{
id: 'true',
name: t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.LOGGED_IN_TRUE'),
},
{
id: 'false',
name: t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.LOGGED_IN_FALSE'),
},
]
),
conversationOption(
'browser_language',
t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.BROWSER_LANGUAGE'),
languages
),
conversationOption(
'conversation_language',
t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.CONVERSATION_LANGUAGE'),
languages
),
]);
const standardTypes = computed(() =>
contactFilterTypes.value
.filter(type => type.attributeModel !== 'customAttributes')
.map(type => ({
...type,
icon: STANDARD_ICONS[type.attributeKey] || DEFAULT_ICON,
}))
);
const customTypes = computed(() =>
contactFilterTypes.value
.filter(type => type.attributeModel === 'customAttributes')
.filter(type => type.attributeKey in customTypeByKey.value)
.map(type => ({
...type,
icon:
CUSTOM_TYPE_ICONS[customTypeByKey.value[type.attributeKey]] ||
DEFAULT_ICON,
}))
);
const filterTypes = computed(() => [
header('contact', t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.GROUP_CONTACT')),
...standardTypes.value,
header(
'conversation',
t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.GROUP_CONVERSATION')
),
...conversationTypes.value,
...(customTypes.value.length
? [
header('custom', t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.GROUP_CUSTOM')),
...customTypes.value,
]
: []),
]);
return { filterTypes };
}
@@ -0,0 +1,99 @@
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
import { useAudienceFilterTypes } from './useAudienceFilterTypes';
const state = vi.hoisted(() => ({
contactFilterTypes: [],
contactAttributes: [],
equalityOperators: [{ value: 'equal_to' }, { value: 'not_equal_to' }],
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: key => key }),
}));
vi.mock('dashboard/composables/store.js', () => ({
useMapGetter: () => ({ value: state.contactAttributes }),
}));
vi.mock('dashboard/components-next/filter/contactProvider.js', () => ({
useContactFilterContext: () => ({
filterTypes: { value: state.contactFilterTypes },
}),
}));
vi.mock('dashboard/components-next/filter/operators.js', () => ({
useOperators: () => ({
equalityOperators: { value: state.equalityOperators },
}),
}));
describe('useAudienceFilterTypes', () => {
beforeEach(() => {
state.contactFilterTypes = [
{ attributeKey: 'email', attributeModel: 'standard' },
{ attributeKey: 'mystery', attributeModel: 'standard' },
{ attributeKey: 'signed_up_on', attributeModel: 'customAttributes' },
{ attributeKey: 'company_size', attributeModel: 'customAttributes' },
];
state.contactAttributes = [
{ attributeKey: 'signed_up_on', attributeDisplayType: 'date' },
];
});
const build = () => useAudienceFilterTypes().filterTypes.value;
it('assembles grouped sections in order', () => {
const keys = build().map(item => item.attributeKey ?? item.value);
expect(keys).toEqual([
'__group_contact',
'email',
'mystery',
'__group_conversation',
'hmac_verified',
'browser_language',
'conversation_language',
'__group_custom',
'signed_up_on',
]);
});
it('marks headers as disabled options', () => {
const headers = build().filter(item => item.value?.startsWith('__group_'));
expect(headers).toHaveLength(3);
expect(headers.every(header => header.disabled)).toBe(true);
});
it('excludes custom attributes that are not contact-model attributes', () => {
const keys = build().map(item => item.attributeKey);
expect(keys).not.toContain('company_size');
});
it('omits the custom section when there are no contact custom attributes', () => {
state.contactAttributes = [];
const values = build().map(item => item.attributeKey ?? item.value);
expect(values).not.toContain('__group_custom');
expect(values).not.toContain('signed_up_on');
});
it('assigns icons by attribute key with a default fallback', () => {
const byKey = Object.fromEntries(
build().map(item => [item.attributeKey, item.icon])
);
expect(byKey.email).toBe('i-lucide-mail');
expect(byKey.mystery).toBe('i-lucide-tag');
expect(byKey.signed_up_on).toBe('i-lucide-calendar');
});
it('builds conversation options as searchSelects with equality operators', () => {
const types = build();
const hmac = types.find(item => item.attributeKey === 'hmac_verified');
expect(hmac.inputType).toBe('searchSelect');
expect(hmac.filterOperators).toEqual(state.equalityOperators);
expect(hmac.options.map(option => option.id)).toEqual(['true', 'false']);
const browserLanguage = types.find(
item => item.attributeKey === 'browser_language'
);
expect(browserLanguage.options).toEqual(languages);
});
});
@@ -15,6 +15,7 @@ import { validateSingleFilter } from 'dashboard/helper/validations.js';
const { filterTypes } = defineProps({
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);
},
}
);
@@ -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>
@@ -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 },
@@ -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;
});
@@ -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>
@@ -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'),
@@ -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);
+1
View File
@@ -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)
+32 -1
View File
@@ -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
+7 -7
View File
@@ -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)