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:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
28
spec/enterprise/models/enterprise/conversation_spec.rb
Normal file
28
spec/enterprise/models/enterprise/conversation_spec.rb
Normal 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
|
||||
194
spec/enterprise/services/captain/audience_matcher_spec.rb
Normal file
194
spec/enterprise/services/captain/audience_matcher_spec.rb
Normal 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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user