feat(captain): Add audience and schedule controls for assistants (#14902)

Captain assistants now support **audience** and **schedule** controls,
so you can decide *who* an assistant replies to and *when* it's on duty.
By default nothing changes, an assistant still responds to every
conversation in its connected inboxes but you can now narrow that down.

- **Audience**: build a condition tree (contact attributes, conversation
attributes, and custom attributes) with and/or groups, mirroring the
contact-segment filter semantics. Only conversations whose contact
matches the audience get a Captain reply.
- **Schedule**: choose when Captain replies — *Anytime*, *During
business hours*, or *Outside business hours* (based on each inbox's
configured working hours; inboxes without business hours are always
covered).

When an assistant opts out of a conversation (contact outside the
audience, or off-schedule), the conversation is routed to the human
queue instead of being parked pending on a silent bot — both on initial
creation and on reopen.

Fixes
https://linear.app/chatwoot/issue/CW-7414/audience-and-availability-controls

|Audience|Availability|
|--|--|
| <img width="1132" height="627" alt="Screenshot 2026-06-30 at 5 52
09 PM"
src="https://github.com/user-attachments/assets/866910e0-e1d7-4248-8630-d91afc758688"
/> | <img width="1131" height="539" alt="Screenshot 2026-06-30 at 5 52
13 PM"
src="https://github.com/user-attachments/assets/aad0d6f7-ceb7-4546-a049-095c5b46b483"
/> |

## How to test

1. Open **Captain → Assistants → (an assistant) → Settings**.
2. Under **Audience**, add a condition or condition group (e.g. `Contact
language equal_to en`) and save. Start a conversation from a contact
that does *not* match — Captain should stay silent and the conversation
should land in the human (open) queue instead of pending.
3. With a matching contact, Captain should respond as before.
4. Under **Schedule**, pick **During business hours** (or **Outside
business hours**) on an inbox that has working hours configured, and
confirm Captain only engages within/outside that window. An
empty/`Anytime` schedule always responds.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Co-authored-by: aakashb95 <aakashbakhle@gmail.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
This commit is contained in:
Pranav
2026-08-07 00:55:48 -07:00
committed by GitHub
parent 834e9a4044
commit 0f3bb640f5
36 changed files with 2363 additions and 563 deletions

View File

@@ -254,6 +254,47 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
expect(response).to have_http_status(:success)
expect(assistant.reload.config).to include('product_name' => 'Chatwoot', 'auto_resolve_mode' => 'disabled')
end
it 'persists the nested audience condition tree' do
create(:custom_attribute_definition, account: account, attribute_model: :contact_attribute,
attribute_display_type: :text, attribute_key: 'plan_tier')
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
it 'rejects invalid audience attributes and operators' do
invalid_audiences = [
{ attribute_key: 'missing_attribute', filter_operator: 'not_equal_to', values: ['known'] },
{ attribute_key: 'blocked', filter_operator: 'is_not_present', values: [] }
]
invalid_audiences.each do |audience|
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(:unprocessable_content)
end
end
end
end

View File

@@ -1,10 +1,208 @@
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 an operator unsupported by a standard attribute' do
assistant.config['audience'] = leaf.merge('attribute_key' => 'blocked', 'filter_operator' => 'is_not_present', 'values' => [])
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 conversation language conditions' do
assistant.config['audience'] = {
'attribute_key' => 'conversation_language', 'filter_operator' => 'equal_to', 'values' => ['en']
}
expect(assistant).not_to be_valid
end
it 'rejects an unknown custom attribute' do
assistant.config['audience'] = {
'attribute_key' => 'missing_attribute', 'filter_operator' => 'not_equal_to', 'values' => ['known']
}
expect(assistant).not_to be_valid
end
it 'accepts an operator supported by a defined custom attribute' do
create(:custom_attribute_definition, account: account, attribute_model: :contact_attribute,
attribute_display_type: :date, attribute_key: 'signed_up_on')
assistant.config['audience'] = {
'attribute_key' => 'signed_up_on', 'filter_operator' => 'is_present', 'values' => []
}
expect(assistant).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 an unknown group operator' do
assistant.config['audience'] = { 'operator' => 'xor', 'conditions' => [leaf] }
expect(assistant).not_to be_valid
end
it 'rejects a value-taking leaf without values' do
assistant.config['audience'] = { 'attribute_key' => 'email', 'filter_operator' => 'contains' }
expect(assistant).not_to be_valid
end
it 'rejects a valueless operator when the attribute does not support it' do
assistant.config['audience'] = { 'attribute_key' => 'email', 'filter_operator' => 'is_present' }
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)

View File

@@ -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

View File

@@ -0,0 +1,194 @@
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' })
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 'compares numeric custom attribute values with UI strings' do
create(:custom_attribute_definition, account: account, attribute_model: :contact_attribute,
attribute_display_type: :number, attribute_key: 'annual_spend')
contact.update!(custom_attributes: contact.custom_attributes.merge('annual_spend' => 120.5))
expect(matches?(leaf('annual_spend', 'equal_to', '120.5'))).to be(true)
expect(matches?(leaf('annual_spend', 'equal_to', '120.6'))).to be(false)
contact.update!(custom_attributes: contact.custom_attributes.merge('annual_spend' => '120.50'))
expect(matches?(leaf('annual_spend', 'equal_to', '120.5'))).to be(true)
expect(matches?(leaf('annual_spend', 'not_equal_to', '120.5'))).to be(false)
end
it 'does not include missing standard and additional attributes in negative matches' do
contact.update!(email: 'person@chatwoot.com', identifier: nil,
additional_attributes: contact.additional_attributes.except('country_code'))
expect(matches?(leaf('identifier', 'not_equal_to', 'known'))).to be(false)
expect(matches?(leaf('country_code', 'not_equal_to', 'US'))).to be(false)
expect(matches?(leaf('email', 'does_not_contain', 'example.com'))).to be(true)
expect(matches?(leaf('identifier', 'does_not_contain', 'known'))).to be(false)
end
it 'preserves custom attribute null semantics from contact filters' do
create(:custom_attribute_definition, account: account, attribute_model: :contact_attribute,
attribute_display_type: :text, attribute_key: 'missing_custom_attribute')
expect(matches?(leaf('missing_custom_attribute', 'not_equal_to', 'known'))).to be(true)
expect(matches?(leaf('missing_custom_attribute', 'does_not_contain', 'known'))).to be(false)
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
create(:custom_attribute_definition, account: account, attribute_key: 'newsletter_opt_in',
attribute_model: 'contact_attribute', attribute_display_type: 'checkbox')
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)
expect(matches?(leaf('newsletter_opt_in', 'not_equal_to', 'false'))).to be(false)
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 browser language' do
it 'resolves browser_language from the conversation' do
expect(matches?(leaf('browser_language', 'equal_to', 'en'))).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

View File

@@ -224,6 +224,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)