-
+
+
+
+
+
-
-
+
+
+
+
+ {{ t('CAPTAIN.ASSISTANTS.SETTINGS.DELETE.TITLE') }}
+
+
+ {{ t('CAPTAIN.ASSISTANTS.SETTINGS.DELETE.DESCRIPTION') }}
+
+
+
+
+
+
+
+
+
-
-
-
-
- {{ t('CAPTAIN.ASSISTANTS.SETTINGS.DELETE.TITLE') }}
-
-
- {{ t('CAPTAIN.ASSISTANTS.SETTINGS.DELETE.DESCRIPTION') }}
-
-
-
-
-
-
-
-
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
index df9dfe5cc..282385a4a 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
@@ -59,7 +59,7 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
:product_name, :feature_faq, :feature_memory, :feature_citation,
:feature_contact_attributes,
:welcome_message, :handoff_message, :resolution_message,
- :instructions, :temperature
+ :instructions, :temperature, :response_window
])
# Handle array parameters separately to allow partial updates
@@ -67,9 +67,22 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
permitted[:guardrails] = params[:assistant][:guardrails] if params[:assistant].key?(:guardrails)
+ # The audience is a recursive condition tree that strong params can't whitelist by shape;
+ # route it through separately. Validity is enforced by Captain::Assistant#validate_audience_structure.
+ permit_audience_config(permitted)
+
permitted
end
+ def permit_audience_config(permitted)
+ config = params[:assistant][:config]
+ return unless config.respond_to?(:key?) && config.key?(:audience)
+
+ audience = config[:audience]
+ permitted[:config] ||= ActionController::Parameters.new.permit!
+ permitted[:config][:audience] = audience.respond_to?(:permit!) ? audience.permit!.to_h : audience
+ end
+
def playground_params
params.require(:assistant).permit(:message_content, message_history: [:role, :content, :agent_name])
end
diff --git a/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb
index 0735987de..55558879e 100644
--- a/enterprise/app/models/captain/assistant.rb
+++ b/enterprise/app/models/captain/assistant.rb
@@ -36,11 +36,15 @@ class Captain::Assistant < ApplicationRecord
has_many :copilot_threads, dependent: :destroy_async
has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async
- store_accessor :config, :temperature, :feature_faq, :feature_memory, :feature_contact_attributes, :product_name
+ store_accessor :config, :temperature, :feature_faq, :feature_memory, :feature_contact_attributes, :product_name, :response_window
+
+ RESPONSE_WINDOWS = %w[always business_hours outside_business_hours].freeze
validates :name, presence: true
validates :description, presence: true
validates :account_id, presence: true
+ validate :validate_audience_structure
+ validate :validate_response_window
scope :ordered, -> { order(created_at: :desc) }
@@ -50,6 +54,32 @@ class Captain::Assistant < ApplicationRecord
name
end
+ # Whether this assistant should engage the given conversation right now — combines the audience
+ # filter (who) and the schedule (when).
+ def engages?(contact, conversation)
+ responds_to_audience?(contact, conversation) && available_now?(conversation)
+ end
+
+ # Whether this assistant should engage the given contact, based on its audience filter.
+ # No audience configured => responds to everyone (back-compat).
+ def responds_to_audience?(contact, conversation)
+ return true if config['audience'].blank?
+
+ Captain::AudienceMatcher.new(config['audience']).matches?(contact, conversation)
+ end
+
+ # Whether the assistant is on duty for this conversation based on the response window.
+ # Inboxes without business hours configured are always covered (fail open).
+ def available_now?(conversation)
+ window = config['response_window']
+ return true if window.blank? || window == 'always'
+
+ inbox = conversation.inbox
+ return true unless inbox.working_hours_enabled?
+
+ window == 'business_hours' ? !inbox.out_of_office? : inbox.out_of_office?
+ end
+
def available_agent_tools
tools = self.class.built_in_agent_tools.dup
@@ -87,6 +117,39 @@ class Captain::Assistant < ApplicationRecord
private
+ def validate_audience_structure
+ audience = config['audience']
+ return if audience.blank?
+
+ errors.add(:config, 'audience must be a valid condition tree') unless valid_audience_node?(audience, 1)
+ end
+
+ def validate_response_window
+ window = config['response_window']
+ return if window.blank?
+
+ errors.add(:config, 'invalid response_window') unless RESPONSE_WINDOWS.include?(window)
+ end
+
+ def valid_audience_node?(node, depth)
+ return false unless node.is_a?(Hash) && depth <= Captain::AudienceMatcher::MAX_DEPTH
+
+ node = node.with_indifferent_access
+ return valid_audience_group?(node, depth) if node.key?(:conditions)
+
+ valid_audience_leaf?(node)
+ end
+
+ def valid_audience_group?(node, depth)
+ node[:conditions].is_a?(Array) &&
+ node[:conditions].present? &&
+ node[:conditions].all? { |child| valid_audience_node?(child, depth + 1) }
+ end
+
+ def valid_audience_leaf?(node)
+ node[:attribute_key].present? && Captain::AudienceMatcher::OPERATORS.include?(node[:filter_operator])
+ end
+
def agent_name
name.parameterize(separator: '_')
end
diff --git a/enterprise/app/models/enterprise/conversation.rb b/enterprise/app/models/enterprise/conversation.rb
index 077e0e932..2addc1a89 100644
--- a/enterprise/app/models/enterprise/conversation.rb
+++ b/enterprise/app/models/enterprise/conversation.rb
@@ -33,6 +33,25 @@ module Enterprise::Conversation
private
+ # When a Captain inbox parks new conversations as pending, demote to open (human queue) when the
+ # assistant won't engage (contact outside audience, or off-schedule) so they aren't stuck waiting
+ # on a bot that stays silent.
+ def determine_conversation_status
+ super
+ return unless pending?
+
+ self.status = :open unless captain_should_engage?
+ end
+
+ # True for non-Captain inboxes (don't interfere) and for Captain inboxes that should engage this
+ # conversation (audience matches AND on-schedule). False only when a Captain assistant opts out.
+ def captain_should_engage?
+ assistant = inbox.captain_assistant
+ return true if assistant.blank?
+
+ assistant.engages?(contact, self)
+ end
+
def dispatch_captain_inference_event(event_name)
dispatcher_dispatch(event_name)
end
diff --git a/enterprise/app/models/enterprise/message.rb b/enterprise/app/models/enterprise/message.rb
index a6336fc55..69b4db8fe 100644
--- a/enterprise/app/models/enterprise/message.rb
+++ b/enterprise/app/models/enterprise/message.rb
@@ -15,6 +15,22 @@ module Enterprise::Message
private
+ # On reopen, a Captain inbox would normally re-pend the conversation for the bot. When the
+ # assistant won't engage (contact outside audience, or off-schedule), route to the human queue
+ # (open) instead of pending.
+ def reopen_resolved_conversation
+ return conversation.open! if captain_should_not_engage?
+
+ super
+ end
+
+ def captain_should_not_engage?
+ inbox = conversation.inbox
+ inbox.respond_to?(:captain_assistant) &&
+ inbox.captain_assistant.present? &&
+ !inbox.captain_assistant.engages?(conversation.contact, conversation)
+ end
+
def mark_pending_conversation_as_open_for_human_response
return unless captain_pending_conversation?
return unless human_response?
diff --git a/enterprise/app/services/captain/audience_matcher.rb b/enterprise/app/services/captain/audience_matcher.rb
new file mode 100644
index 000000000..31fccd1f1
--- /dev/null
+++ b/enterprise/app/services/captain/audience_matcher.rb
@@ -0,0 +1,153 @@
+# Evaluates a Captain assistant's audience tree in-memory against the conversation's contact
+# (plus the two conversation language fields). Zero DB queries on the hot path.
+#
+# A node is either:
+# - a GROUP: { "operator" => "and"|"or", "conditions" => [node, ...] }
+# - a LEAF: { "attribute_key" => "...", "filter_operator" => "...", "values" => [...] }
+#
+# Operator semantics mirror Chatwoot's FilterService (see app/services/filter_service.rb and
+# app/services/contacts/filter_service.rb) so the audience agrees with what the same conditions
+# would match in the contact segment UI.
+class Captain::AudienceMatcher
+ CONTACT_STANDARD = %w[name email phone_number identifier blocked created_at last_activity_at].freeze
+ CONTACT_ADDITIONAL = %w[country_code city company_name].freeze
+ CONVERSATION_ADDITIONAL = %w[browser_language conversation_language].freeze
+ OPERATORS = %w[equal_to not_equal_to contains does_not_contain is_present is_not_present starts_with
+ is_greater_than is_less_than days_before].freeze
+ # One level of nesting: root group (depth 1) -> sub-group (depth 2) -> leaves (depth 3).
+ MAX_DEPTH = 3
+
+ def initialize(audience)
+ @root = audience
+ end
+
+ def matches?(contact, conversation)
+ return true if @root.blank?
+
+ evaluate(@root, contact, conversation)
+ end
+
+ private
+
+ def evaluate(node, contact, conversation)
+ node = node.with_indifferent_access
+ return evaluate_group(node, contact, conversation) if node.key?(:conditions)
+
+ evaluate_leaf(node, contact, conversation)
+ end
+
+ def evaluate_group(node, contact, conversation)
+ results = Array(node[:conditions]).map { |child| evaluate(child, contact, conversation) }
+ node[:operator].to_s.casecmp?('or') ? results.any? : results.all?
+ end
+
+ def evaluate_leaf(node, contact, conversation)
+ key = node[:attribute_key]
+ actual = resolve_value(key, contact, conversation)
+ apply_operator(node[:filter_operator], key, actual, node[:values])
+ end
+
+ def resolve_value(key, contact, conversation)
+ case key
+ when *CONTACT_STANDARD then contact.public_send(key)
+ when *CONTACT_ADDITIONAL then contact.additional_attributes[key]
+ when 'labels' then contact.label_list
+ else resolve_conversation_value(key, contact, conversation)
+ end
+ end
+
+ def resolve_conversation_value(key, contact, conversation)
+ case key
+ when *CONVERSATION_ADDITIONAL then conversation.additional_attributes[key]
+ when 'hmac_verified' then conversation.contact_inbox&.hmac_verified || false
+ else contact.custom_attributes[key]
+ end
+ end
+
+ def apply_operator(operator, key, actual, values)
+ expected = values.is_a?(Array) ? values.first : values
+
+ case operator
+ when 'equal_to' then value_equal?(key, actual, expected)
+ when 'not_equal_to' then !value_equal?(key, actual, expected)
+ when 'is_present' then actual.present?
+ when 'is_not_present' then actual.blank?
+ else extended_operator(operator, actual, expected)
+ end
+ end
+
+ def extended_operator(operator, actual, expected)
+ case operator
+ when 'contains' then downcase(actual).include?(downcase(expected))
+ when 'does_not_contain' then downcase(actual).exclude?(downcase(expected))
+ when 'starts_with' then downcase(actual).start_with?(downcase(expected))
+ when 'is_greater_than' then compare(actual, expected, :>)
+ when 'is_less_than' then compare(actual, expected, :<)
+ when 'days_before' then date_before?(actual, expected)
+ else false
+ end
+ end
+
+ def value_equal?(key, actual, expected)
+ return Array(actual).include?(expected) if key == 'labels'
+ return ActiveModel::Type::Boolean.new.cast(expected) == actual if [true, false].include?(actual)
+
+ normalize(key, actual) == normalize(key, expected)
+ end
+
+ def normalize(key, value)
+ return value if value.nil?
+
+ case key
+ when 'phone_number' then "+#{value.to_s.delete('+')}"
+ when 'country_code' then value.to_s.downcase
+ else value.is_a?(String) ? value.downcase : value
+ end
+ end
+
+ def downcase(value)
+ value.to_s.downcase
+ end
+
+ def compare(actual, expected, operator)
+ return false if actual.blank?
+
+ actual_value, expected_value = coerce_pair(actual, expected)
+ return false if actual_value.nil? || expected_value.nil?
+
+ actual_value.public_send(operator, expected_value)
+ end
+
+ def coerce_pair(actual, expected)
+ if date_like?(actual)
+ [actual.to_time, parse_time(expected)]
+ else
+ [BigDecimal(actual.to_s), BigDecimal(expected.to_s)]
+ end
+ rescue ArgumentError, TypeError
+ [nil, nil]
+ end
+
+ def date_before?(actual, expected)
+ actual_date = to_date(actual)
+ return false if actual_date.nil?
+
+ actual_date < (Time.zone.today - expected.to_i.days)
+ end
+
+ def date_like?(value)
+ value.is_a?(Date) || value.is_a?(Time) || value.is_a?(ActiveSupport::TimeWithZone)
+ end
+
+ def parse_time(value)
+ Time.zone.parse(value.to_s)
+ end
+
+ def to_date(value)
+ return value.to_date if value.respond_to?(:to_date)
+
+ Date.parse(value.to_s)
+ rescue ArgumentError, TypeError
+ nil
+ end
+end
diff --git a/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb b/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb
index 56dbc7245..ac1c9e0c3 100644
--- a/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb
+++ b/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb
@@ -50,7 +50,7 @@ module Enterprise::MessageTemplates::HookExecutionService
end
def should_process_captain_response?
- conversation.pending? && message.incoming? && inbox.captain_assistant.present?
+ conversation.pending? && message.incoming? && captain_should_engage?
end
def perform_handoff
@@ -76,6 +76,15 @@ module Enterprise::MessageTemplates::HookExecutionService
end
def captain_handling_conversation?
- conversation.pending? && inbox.respond_to?(:captain_assistant) && inbox.captain_assistant.present?
+ conversation.pending? && captain_should_engage?
+ end
+
+ # True only when the inbox has a Captain assistant that should engage this conversation now —
+ # i.e. the contact is within the audience AND the assistant is on-schedule. Otherwise the
+ # conversation falls back to normal human handling (greeting/OOO templates fire, Captain silent).
+ def captain_should_engage?
+ inbox.respond_to?(:captain_assistant) &&
+ inbox.captain_assistant.present? &&
+ inbox.captain_assistant.engages?(conversation.contact, conversation)
end
end
diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb
index 4689defaf..caa815642 100644
--- a/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb
+++ b/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb
@@ -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
diff --git a/spec/enterprise/models/captain/assistant_spec.rb b/spec/enterprise/models/captain/assistant_spec.rb
new file mode 100644
index 000000000..6724abe53
--- /dev/null
+++ b/spec/enterprise/models/captain/assistant_spec.rb
@@ -0,0 +1,115 @@
+require 'rails_helper'
+
+RSpec.describe Captain::Assistant, type: :model do
+ let(:account) { create(:account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:contact) { create(:contact, account: account, additional_attributes: { 'country_code' => 'US' }) }
+ let(:conversation) { create(:conversation, account: account, contact: contact) }
+
+ describe '#responds_to_audience?' do
+ it 'returns true when no audience is configured' do
+ expect(assistant.responds_to_audience?(contact, conversation)).to be(true)
+ end
+
+ it 'returns true when the contact matches the audience' do
+ assistant.update!(config: assistant.config.merge('audience' => {
+ 'attribute_key' => 'country_code', 'filter_operator' => 'equal_to', 'values' => ['US']
+ }))
+ expect(assistant.responds_to_audience?(contact, conversation)).to be(true)
+ end
+
+ it 'returns false when the contact does not match the audience' do
+ assistant.update!(config: assistant.config.merge('audience' => {
+ 'attribute_key' => 'country_code', 'filter_operator' => 'equal_to', 'values' => ['CA']
+ }))
+ expect(assistant.responds_to_audience?(contact, conversation)).to be(false)
+ end
+ end
+
+ describe '#available_now?' do
+ let(:inbox) { create(:inbox, account: account) }
+ let(:scheduled_conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
+
+ it 'is available when the window is blank or always' do
+ expect(assistant.available_now?(scheduled_conversation)).to be(true)
+ assistant.config['response_window'] = 'always'
+ expect(assistant.available_now?(scheduled_conversation)).to be(true)
+ end
+
+ it 'is available regardless when the inbox has no business hours configured' do
+ inbox.update!(working_hours_enabled: false)
+ assistant.config['response_window'] = 'business_hours'
+ expect(assistant.available_now?(scheduled_conversation)).to be(true)
+ end
+
+ context 'when the inbox has business hours enabled' do
+ before { inbox.update!(working_hours_enabled: true) }
+
+ it 'business_hours matches only when the inbox is open' do
+ assistant.config['response_window'] = 'business_hours'
+ allow(scheduled_conversation.inbox).to receive(:out_of_office?).and_return(false)
+ expect(assistant.available_now?(scheduled_conversation)).to be(true)
+ allow(scheduled_conversation.inbox).to receive(:out_of_office?).and_return(true)
+ expect(assistant.available_now?(scheduled_conversation)).to be(false)
+ end
+
+ it 'outside_business_hours matches only when the inbox is closed' do
+ assistant.config['response_window'] = 'outside_business_hours'
+ allow(scheduled_conversation.inbox).to receive(:out_of_office?).and_return(true)
+ expect(assistant.available_now?(scheduled_conversation)).to be(true)
+ allow(scheduled_conversation.inbox).to receive(:out_of_office?).and_return(false)
+ expect(assistant.available_now?(scheduled_conversation)).to be(false)
+ end
+ end
+ end
+
+ describe 'response_window validation' do
+ it 'accepts the known windows' do
+ %w[always business_hours outside_business_hours].each do |window|
+ assistant.config['response_window'] = window
+ expect(assistant).to be_valid
+ end
+ end
+
+ it 'rejects an unknown window' do
+ assistant.config['response_window'] = 'weekends'
+ expect(assistant).not_to be_valid
+ end
+ end
+
+ describe 'audience validation' do
+ it 'accepts a well-formed nested tree' do
+ assistant.config['audience'] = {
+ 'operator' => 'and',
+ 'conditions' => [
+ { 'attribute_key' => 'country_code', 'filter_operator' => 'equal_to', 'values' => ['US'] }
+ ]
+ }
+ expect(assistant).to be_valid
+ end
+
+ it 'rejects an unknown operator' do
+ assistant.config['audience'] = { 'attribute_key' => 'country_code', 'filter_operator' => 'bogus', 'values' => ['US'] }
+ expect(assistant).not_to be_valid
+ end
+
+ it 'rejects a group without conditions' do
+ assistant.config['audience'] = { 'operator' => 'and', 'conditions' => [] }
+ expect(assistant).not_to be_valid
+ end
+
+ it 'rejects nesting deeper than one level' do
+ assistant.config['audience'] = {
+ 'operator' => 'and',
+ 'conditions' => [
+ { 'operator' => 'or', 'conditions' => [
+ { 'operator' => 'and', 'conditions' => [
+ { 'attribute_key' => 'country_code', 'filter_operator' => 'equal_to', 'values' => ['US'] }
+ ] }
+ ] }
+ ]
+ }
+ expect(assistant).not_to be_valid
+ end
+ end
+end
diff --git a/spec/enterprise/models/enterprise/conversation_spec.rb b/spec/enterprise/models/enterprise/conversation_spec.rb
new file mode 100644
index 000000000..b1cf87cb1
--- /dev/null
+++ b/spec/enterprise/models/enterprise/conversation_spec.rb
@@ -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
diff --git a/spec/enterprise/services/captain/audience_matcher_spec.rb b/spec/enterprise/services/captain/audience_matcher_spec.rb
new file mode 100644
index 000000000..8f574f083
--- /dev/null
+++ b/spec/enterprise/services/captain/audience_matcher_spec.rb
@@ -0,0 +1,131 @@
+require 'rails_helper'
+
+RSpec.describe Captain::AudienceMatcher do
+ let(:account) { create(:account) }
+ let(:contact) do
+ create(:contact, :with_email, :with_phone_number, account: account,
+ additional_attributes: { 'country_code' => 'US', 'city' => 'Boston', 'company_name' => 'Acme' },
+ custom_attributes: { 'plan_tier' => 'paid' })
+ end
+ let(:conversation) do
+ create(:conversation, account: account, contact: contact,
+ additional_attributes: { 'browser_language' => 'en', 'conversation_language' => 'fr' })
+ end
+
+ def leaf(attribute_key, filter_operator, values = nil)
+ { 'attribute_key' => attribute_key, 'filter_operator' => filter_operator, 'values' => Array(values) }
+ end
+
+ def matches?(audience)
+ described_class.new(audience).matches?(contact, conversation)
+ end
+
+ describe '#matches?' do
+ it 'returns true when the audience is blank' do
+ expect(matches?(nil)).to be(true)
+ expect(matches?({})).to be(true)
+ end
+
+ context 'with contact attribute leaves' do
+ it 'matches additional_attributes case-insensitively for country_code' do
+ expect(matches?(leaf('country_code', 'equal_to', 'us'))).to be(true)
+ expect(matches?(leaf('country_code', 'equal_to', 'ca'))).to be(false)
+ end
+
+ it 'matches custom attributes' do
+ expect(matches?(leaf('plan_tier', 'equal_to', 'paid'))).to be(true)
+ expect(matches?(leaf('plan_tier', 'not_equal_to', 'free'))).to be(true)
+ end
+
+ it 'supports contains / starts_with on text' do
+ expect(matches?(leaf('email', 'contains', contact.email[2..5]))).to be(true)
+ expect(matches?(leaf('city', 'starts_with', 'Bos'))).to be(true)
+ end
+
+ it 'normalizes phone numbers' do
+ expect(matches?(leaf('phone_number', 'equal_to', contact.phone_number.delete('+')))).to be(true)
+ end
+
+ it 'supports presence checks' do
+ expect(matches?(leaf('email', 'is_present'))).to be(true)
+ expect(matches?(leaf('identifier', 'is_not_present'))).to be(true)
+ end
+
+ it 'matches blocked boolean' do
+ contact.update!(blocked: true)
+ expect(matches?(leaf('blocked', 'equal_to', 'true'))).to be(true)
+ end
+
+ it 'supports days_before on created_at' do
+ contact.update!(created_at: 40.days.ago)
+ expect(matches?(leaf('created_at', 'days_before', '30'))).to be(true)
+ expect(matches?(leaf('created_at', 'days_before', '60'))).to be(false)
+ end
+ end
+
+ context 'with labels' do
+ before { contact.update_labels(%w[vip]) }
+
+ it 'matches has-tag semantics' do
+ expect(matches?(leaf('labels', 'equal_to', 'vip'))).to be(true)
+ expect(matches?(leaf('labels', 'equal_to', 'enterprise'))).to be(false)
+ end
+ end
+
+ context 'with conversation language fields' do
+ it 'resolves browser_language and conversation_language from the conversation' do
+ expect(matches?(leaf('browser_language', 'equal_to', 'en'))).to be(true)
+ expect(matches?(leaf('conversation_language', 'equal_to', 'fr'))).to be(true)
+ end
+ end
+
+ context 'with the logged-in (hmac_verified) flag' do
+ it 'matches a verified contact inbox' do
+ conversation.contact_inbox.update!(hmac_verified: true)
+ expect(matches?(leaf('hmac_verified', 'equal_to', 'true'))).to be(true)
+ expect(matches?(leaf('hmac_verified', 'equal_to', 'false'))).to be(false)
+ end
+
+ it 'treats an unverified contact inbox as not logged in' do
+ conversation.contact_inbox.update!(hmac_verified: false)
+ expect(matches?(leaf('hmac_verified', 'equal_to', 'false'))).to be(true)
+ expect(matches?(leaf('hmac_verified', 'equal_to', 'true'))).to be(false)
+ end
+ end
+
+ context 'with nested groups' do
+ let(:audience) do
+ {
+ 'operator' => 'and',
+ 'conditions' => [
+ leaf('country_code', 'equal_to', 'US'),
+ {
+ 'operator' => 'or',
+ 'conditions' => [
+ leaf('created_at', 'days_before', '3650'),
+ leaf('plan_tier', 'equal_to', 'paid')
+ ]
+ }
+ ]
+ }
+ end
+
+ it 'evaluates OR inside AND with correct precedence' do
+ expect(matches?(audience)).to be(true)
+ end
+
+ it 'fails the AND when the top-level condition is false' do
+ audience['conditions'][0] = leaf('country_code', 'equal_to', 'CA')
+ expect(matches?(audience)).to be(false)
+ end
+
+ it 'fails when neither OR branch matches' do
+ audience['conditions'][1]['conditions'] = [
+ leaf('created_at', 'days_before', '3650'),
+ leaf('plan_tier', 'equal_to', 'free')
+ ]
+ expect(matches?(audience)).to be(false)
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/services/enterprise/message_templates/hook_execution_service_spec.rb b/spec/enterprise/services/enterprise/message_templates/hook_execution_service_spec.rb
index 9eacb0ba1..c0fa2654c 100644
--- a/spec/enterprise/services/enterprise/message_templates/hook_execution_service_spec.rb
+++ b/spec/enterprise/services/enterprise/message_templates/hook_execution_service_spec.rb
@@ -126,6 +126,36 @@ RSpec.describe MessageTemplates::HookExecutionService do
end
end
+ context 'when the contact is outside the assistant audience' do
+ before do
+ assistant.update!(config: assistant.config.merge('audience' => {
+ 'attribute_key' => 'country_code', 'filter_operator' => 'equal_to', 'values' => ['US']
+ }))
+ contact.update!(additional_attributes: { 'country_code' => 'CA' })
+ end
+
+ it 'does not schedule captain response job' do
+ expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)
+
+ create(:message, conversation: conversation, message_type: :incoming, account: account)
+ end
+ end
+
+ context 'when the contact is inside the assistant audience' do
+ before do
+ assistant.update!(config: assistant.config.merge('audience' => {
+ 'attribute_key' => 'country_code', 'filter_operator' => 'equal_to', 'values' => ['US']
+ }))
+ contact.update!(additional_attributes: { 'country_code' => 'US' })
+ end
+
+ it 'schedules captain response job' do
+ expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
+
+ create(:message, conversation: conversation, message_type: :incoming, account: account)
+ end
+ end
+
context 'when message is outgoing' do
it 'does not schedule captain response job' do
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)