-
-
+
+
+
+ {{ item.content }}
+
+
-
-
- {{ t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.EMPTY_MESSAGE') }}
-
-
-
-
- {{
- t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.SEARCH_EMPTY_MESSAGE')
- }}
-
-
-
- handleRuleHover(isHovered, guideline.id)"
- @edit="editGuideline"
- @delete="deleteGuideline"
+ ghost
+ xs
+ slate
+ class="!text-sm !text-n-slate-11 flex-shrink-0"
+ @click="addGuideline(item.content)"
/>
-
+
+
+
+ {{ t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.EMPTY_MESSAGE') }}
+
+
+
+
+ {{ t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.SEARCH_EMPTY_MESSAGE') }}
+
+
+
+ handleRuleHover(isHovered, guideline.id)"
+ @edit="editGuideline"
+ @delete="deleteGuideline"
/>
-
-
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/captain/assistants/settings/Audience.vue b/app/javascript/dashboard/routes/dashboard/captain/assistants/settings/Audience.vue
new file mode 100644
index 000000000..9d44f7923
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/captain/assistants/settings/Audience.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/captain/assistants/settings/Index.vue b/app/javascript/dashboard/routes/dashboard/captain/assistants/settings/Index.vue
new file mode 100644
index 000000000..b20b897aa
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/captain/assistants/settings/Index.vue
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+ {{ t('CAPTAIN.ASSISTANTS.SETTINGS.DELETE.TITLE') }}
+
+
+ {{ t('CAPTAIN.ASSISTANTS.SETTINGS.DELETE.DESCRIPTION') }}
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/captain/assistants/settings/Schedule.vue b/app/javascript/dashboard/routes/dashboard/captain/assistants/settings/Schedule.vue
new file mode 100644
index 000000000..547586564
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/captain/assistants/settings/Schedule.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/captain/assistants/settings/Settings.vue b/app/javascript/dashboard/routes/dashboard/captain/assistants/settings/Settings.vue
deleted file mode 100644
index 7e667ffe9..000000000
--- a/app/javascript/dashboard/routes/dashboard/captain/assistants/settings/Settings.vue
+++ /dev/null
@@ -1,196 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ t('CAPTAIN.ASSISTANTS.SETTINGS.DELETE.TITLE') }}
-
-
- {{ t('CAPTAIN.ASSISTANTS.SETTINGS.DELETE.DESCRIPTION') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/javascript/dashboard/routes/dashboard/captain/assistants/settings/System.vue b/app/javascript/dashboard/routes/dashboard/captain/assistants/settings/System.vue
new file mode 100644
index 000000000..519b8937b
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/captain/assistants/settings/System.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/captain/assistants/settings/useAssistantSettings.js b/app/javascript/dashboard/routes/dashboard/captain/assistants/settings/useAssistantSettings.js
new file mode 100644
index 000000000..adb1d0d17
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/captain/assistants/settings/useAssistantSettings.js
@@ -0,0 +1,31 @@
+import { computed } from 'vue';
+import { useRoute } from 'vue-router';
+import { useI18n } from 'vue-i18n';
+import { useAlert } from 'dashboard/composables';
+import { useStore, useFunctionGetter } from 'dashboard/composables/store';
+
+export function useAssistantSettings() {
+ const { t } = useI18n();
+ const route = useRoute();
+ const store = useStore();
+
+ const assistantId = computed(() => Number(route.params.assistantId));
+ const assistant = useFunctionGetter(
+ 'captainAssistants/getRecord',
+ assistantId
+ );
+
+ const updateAssistant = async updatedAssistant => {
+ try {
+ await store.dispatch('captainAssistants/update', {
+ id: assistantId.value,
+ ...updatedAssistant,
+ });
+ useAlert(t('CAPTAIN.ASSISTANTS.EDIT.SUCCESS_MESSAGE'));
+ } catch (error) {
+ useAlert(error?.message || t('CAPTAIN.ASSISTANTS.EDIT.ERROR_MESSAGE'));
+ }
+ };
+
+ return { assistantId, assistant, updateAssistant };
+}
diff --git a/app/javascript/dashboard/routes/dashboard/captain/assistants/settings/useAssistantSettings.spec.js b/app/javascript/dashboard/routes/dashboard/captain/assistants/settings/useAssistantSettings.spec.js
new file mode 100644
index 000000000..5a528ba39
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/captain/assistants/settings/useAssistantSettings.spec.js
@@ -0,0 +1,71 @@
+import { useAssistantSettings } from './useAssistantSettings';
+import { useStore, useFunctionGetter } from 'dashboard/composables/store';
+import { useAlert } from 'dashboard/composables';
+import { useRoute } from 'vue-router';
+import { useI18n } from 'vue-i18n';
+
+vi.mock('dashboard/composables/store');
+vi.mock('dashboard/composables');
+vi.mock('vue-router');
+vi.mock('vue-i18n');
+
+describe('useAssistantSettings', () => {
+ const assistant = { id: 7, name: 'Support bot' };
+ const mockStore = { dispatch: vi.fn() };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useStore.mockReturnValue(mockStore);
+ useFunctionGetter.mockReturnValue({ value: assistant });
+ useRoute.mockReturnValue({ params: { assistantId: '7' } });
+ useI18n.mockReturnValue({ t: key => key });
+ });
+
+ it('exposes the numeric assistant id from the route', () => {
+ expect(useAssistantSettings().assistantId.value).toBe(7);
+ });
+
+ it('reads the assistant from the store getter', () => {
+ const { assistant: record, assistantId } = useAssistantSettings();
+
+ expect(useFunctionGetter).toHaveBeenCalledWith(
+ 'captainAssistants/getRecord',
+ assistantId
+ );
+ expect(record.value).toEqual(assistant);
+ });
+
+ it('dispatches the update with the assistant id and alerts success', async () => {
+ const { updateAssistant } = useAssistantSettings();
+
+ await updateAssistant({ config: { audience: null } });
+
+ expect(mockStore.dispatch).toHaveBeenCalledWith(
+ 'captainAssistants/update',
+ { id: 7, config: { audience: null } }
+ );
+ expect(useAlert).toHaveBeenCalledWith(
+ 'CAPTAIN.ASSISTANTS.EDIT.SUCCESS_MESSAGE'
+ );
+ });
+
+ it('alerts the error message when the update fails', async () => {
+ mockStore.dispatch.mockRejectedValueOnce(new Error('Audience is invalid'));
+ const { updateAssistant } = useAssistantSettings();
+
+ await updateAssistant({ name: 'New name' });
+
+ expect(useAlert).toHaveBeenCalledWith('Audience is invalid');
+ });
+
+ it('falls back to the generic error message when the error has none', async () => {
+ mockStore.dispatch.mockRejectedValueOnce({});
+ const { updateAssistant } = useAssistantSettings();
+
+ await updateAssistant({ name: 'New name' });
+
+ expect(useAlert).toHaveBeenCalledWith(
+ 'CAPTAIN.ASSISTANTS.EDIT.ERROR_MESSAGE'
+ );
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js b/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js
index b762394a0..c6666f149 100644
--- a/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js
+++ b/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js
@@ -11,7 +11,10 @@ import AssistantsIndexPage from './pages/AssistantsIndexPage.vue';
import AssistantEmptyStateIndex from './assistants/Index.vue';
import AssistantOverviewIndex from './assistants/overview/Index.vue';
-import AssistantSettingsIndex from './assistants/settings/Settings.vue';
+import AssistantSettingsIndex from './assistants/settings/Index.vue';
+import AssistantSystemSettingsIndex from './assistants/settings/System.vue';
+import AssistantAudienceSettingsIndex from './assistants/settings/Audience.vue';
+import AssistantScheduleSettingsIndex from './assistants/settings/Schedule.vue';
import AssistantInboxesIndex from './assistants/inboxes/Index.vue';
import AssistantPlaygroundIndex from './assistants/playground/Index.vue';
import AssistantGuardrailsIndex from './assistants/guardrails/Index.vue';
@@ -110,7 +113,31 @@ const assistantRoutes = [
name: 'captain_assistants_settings_index',
meta,
},
- // Settings sub-pages (guardrails and guidelines)
+ // Settings sub-pages
+ {
+ path: frontendURL(
+ 'accounts/:accountId/captain/:assistantId/settings/system'
+ ),
+ component: AssistantSystemSettingsIndex,
+ name: 'captain_assistants_settings_system_index',
+ meta,
+ },
+ {
+ path: frontendURL(
+ 'accounts/:accountId/captain/:assistantId/settings/audience'
+ ),
+ component: AssistantAudienceSettingsIndex,
+ name: 'captain_assistants_settings_audience_index',
+ meta,
+ },
+ {
+ path: frontendURL(
+ 'accounts/:accountId/captain/:assistantId/settings/schedule'
+ ),
+ component: AssistantScheduleSettingsIndex,
+ name: 'captain_assistants_settings_schedule_index',
+ meta,
+ },
{
path: frontendURL(
'accounts/:accountId/captain/:assistantId/settings/guardrails'
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 f8291fdb8..da21c524c 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
@@ -127,7 +127,8 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
assistant_config_attributes = [
:product_name, :feature_faq, :feature_memory, :feature_citation,
:feature_contact_attributes, :welcome_message, :handoff_message,
- :resolution_message, :instructions, :temperature, :auto_resolve_mode
+ :resolution_message, :instructions, :temperature, :auto_resolve_mode,
+ :response_window
]
permitted = params.require(:assistant).permit(:name, :description,
@@ -138,9 +139,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
diff --git a/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb
index 0149049ec..e8af11f90 100644
--- a/enterprise/app/models/captain/assistant.rb
+++ b/enterprise/app/models/captain/assistant.rb
@@ -20,6 +20,7 @@ class Captain::Assistant < ApplicationRecord
DESCRIPTION_LENGTH_LIMIT = 500
CITATION_SOURCES_STATE_KEY = :captain_v2_citation_sources
AUTO_RESOLVE_MODES = %w[disabled legacy evaluated].freeze
+ RESPONSE_WINDOWS = %w[always business_hours outside_business_hours].freeze
include Avatarable
include Concerns::CaptainToolsHelpers
@@ -44,13 +45,15 @@ class Captain::Assistant < ApplicationRecord
has_many :conversation_outcomes, dependent: :destroy_async
store_accessor :config, :temperature, :feature_faq, :feature_memory, :feature_contact_attributes, :product_name,
- :auto_resolve_mode
+ :auto_resolve_mode, :response_window
before_validation :set_default_auto_resolve_mode, on: :create
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
validates :auto_resolve_mode, inclusion: { in: AUTO_RESOLVE_MODES }
scope :ordered, -> { order(created_at: :desc) }
@@ -61,6 +64,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 auto_resolve_mode
config.fetch('auto_resolve_mode') { account&.captain_auto_resolve_mode || 'evaluated' }
end
@@ -129,6 +152,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 set_default_auto_resolve_mode
return if config.key?('auto_resolve_mode')
diff --git a/enterprise/app/models/enterprise/conversation.rb b/enterprise/app/models/enterprise/conversation.rb
index f57aa3f05..c95f0615c 100644
--- a/enterprise/app/models/enterprise/conversation.rb
+++ b/enterprise/app/models/enterprise/conversation.rb
@@ -25,6 +25,15 @@ module Enterprise::Conversation
private
+ def determine_conversation_status
+ super
+ return unless pending?
+ return if inbox.external_bot_active?
+
+ assistant = inbox.captain_assistant
+ self.status = :open if assistant.present? && !assistant.engages?(contact, self)
+ end
+
def handle_resolved_status_change
super
update_applied_sla_completion
diff --git a/enterprise/app/models/enterprise/message.rb b/enterprise/app/models/enterprise/message.rb
index 11c65c6e5..a84b35d83 100644
--- a/enterprise/app/models/enterprise/message.rb
+++ b/enterprise/app/models/enterprise/message.rb
@@ -29,6 +29,15 @@ module Enterprise::Message
private
+ def reopen_resolved_conversation
+ assistant = conversation.inbox.captain_assistant
+
+ return super if assistant.blank? || conversation.inbox.external_bot_active?
+ 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?
diff --git a/enterprise/app/services/captain/audience_matcher.rb b/enterprise/app/services/captain/audience_matcher.rb
new file mode 100644
index 000000000..4546dbefa
--- /dev/null
+++ b/enterprise/app/services/captain/audience_matcher.rb
@@ -0,0 +1,180 @@
+# 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].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 negative_equality_match?(key, actual, values)
+ 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?(key, actual, expected)
+ return numeric_equal?(actual, expected) if actual.is_a?(Numeric) || numeric_attribute?(key)
+
+ normalize(key, actual) == normalize(key, expected)
+ end
+
+ def numeric_equal?(actual, expected)
+ BigDecimal(actual.to_s) == BigDecimal(expected.to_s)
+ rescue ArgumentError, TypeError
+ false
+ end
+
+ def negative_equality_match?(key, actual, values)
+ if actual.nil?
+ custom_attribute = custom_attribute?(key)
+ return custom_attribute unless custom_attribute && checkbox_attribute?(key)
+ end
+
+ values.none? { |expected| value_equal?(key, actual, expected) }
+ end
+
+ def custom_attribute?(key)
+ CONTACT_STANDARD.exclude?(key) && CONTACT_ADDITIONAL.exclude?(key) && CONVERSATION_ADDITIONAL.exclude?(key) &&
+ %w[labels hmac_verified].exclude?(key)
+ end
+
+ # An unset checkbox attribute counts as false.
+ def boolean_condition?(key, actual, expected)
+ [true, false].include?(actual) ||
+ (actual.nil? && %w[true false].include?(expected.to_s) && checkbox_attribute?(key))
+ end
+
+ def checkbox_attribute?(key)
+ custom_attribute_types[key] == 'checkbox'
+ end
+
+ def numeric_attribute?(key)
+ %w[number currency percent].include?(custom_attribute_types[key])
+ end
+
+ def custom_attribute_types
+ @custom_attribute_types ||= @contact.account.custom_attribute_definitions.contact_attribute.each_with_object({}) do |definition, types|
+ types[definition.attribute_key] = definition.attribute_display_type
+ end
+ 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 excludes_text?(actual, expected)
+ 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
+
+ def excludes_text?(actual, expected)
+ !actual.nil? && actual.to_s.downcase.exclude?(expected.to_s.downcase)
+ 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)
+ expected_date = to_date(expected)
+ actual.to_date <=> expected_date if expected_date
+ 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
diff --git a/enterprise/app/services/captain/conversation/response_scheduler_service.rb b/enterprise/app/services/captain/conversation/response_scheduler_service.rb
new file mode 100644
index 000000000..0e8f6c508
--- /dev/null
+++ b/enterprise/app/services/captain/conversation/response_scheduler_service.rb
@@ -0,0 +1,58 @@
+class Captain::Conversation::ResponseSchedulerService
+ MAX_ATTACHMENT_WAIT_SECONDS = 4
+
+ def initialize(message:)
+ @message = message
+ @conversation = message.conversation
+ @assistant = message.inbox.captain_assistant
+ end
+
+ def perform
+ track_captain_engagement
+
+ wait_time = attachment_wait_time
+ return Captain::Conversation::ResponseBuilderJob.perform_later(*job_args) if wait_time.zero?
+
+ Captain::Conversation::ResponseBuilderJob.set(wait: wait_time).perform_later(*job_args)
+ end
+
+ private
+
+ def job_args
+ args = [@conversation, @assistant]
+ args << @message.id if captain_v2_enabled?
+ args
+ end
+
+ def track_captain_engagement
+ return unless captain_v2_enabled?
+
+ Captain::ConversationEvents.engaged(
+ conversation: @conversation,
+ assistant: @assistant,
+ at: @message.created_at
+ )
+ end
+
+ def captain_v2_enabled?
+ @conversation.account.feature_enabled?('captain_integration_v2')
+ end
+
+ def attachment_wait_time
+ attachment_count = captain_v2_enabled? ? recent_attachment_count : @message.attachments.size
+ return 0.seconds if attachment_count.zero?
+
+ base_wait = 1.second
+ additional_wait = [attachment_count, MAX_ATTACHMENT_WAIT_SECONDS].min.seconds
+ base_wait + additional_wait
+ end
+
+ def recent_attachment_count
+ maximum_wait = (MAX_ATTACHMENT_WAIT_SECONDS + 1).seconds
+
+ @conversation.messages.incoming
+ .joins(:attachments)
+ .where(attachments: { created_at: maximum_wait.ago.. })
+ .count
+ 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 b76dca06f..22997b0ca 100644
--- a/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb
+++ b/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb
@@ -1,13 +1,10 @@
module Enterprise::MessageTemplates::HookExecutionService
- MAX_ATTACHMENT_WAIT_SECONDS = 4
-
def trigger_templates
super
return unless should_process_captain_response?
return perform_handoff unless inbox.captain_active?
- track_captain_engagement
- schedule_captain_response
+ Captain::Conversation::ResponseSchedulerService.new(message: message).perform
end
def should_send_greeting?
@@ -30,59 +27,14 @@ module Enterprise::MessageTemplates::HookExecutionService
private
- def track_captain_engagement
- return unless captain_v2_enabled?
-
- Captain::ConversationEvents.engaged(
- conversation: conversation,
- assistant: inbox.captain_assistant,
- at: message.created_at
- )
- end
-
def captain_v2_enabled?
conversation.account.feature_enabled?('captain_integration_v2')
end
- def schedule_captain_response
- job_args = [conversation, conversation.inbox.captain_assistant]
- captain_v2_enabled = conversation.account.feature_enabled?('captain_integration_v2')
- job_args << message.id if captain_v2_enabled
- wait_time = attachment_wait_time(captain_v2_enabled)
-
- if wait_time.zero?
- Captain::Conversation::ResponseBuilderJob.perform_later(*job_args)
- else
- Captain::Conversation::ResponseBuilderJob.set(wait: wait_time).perform_later(*job_args)
- end
- end
-
- def attachment_wait_time(captain_v2_enabled)
- attachment_count = captain_v2_enabled ? recent_attachment_count : message.attachments.size
- return 0.seconds if attachment_count.zero?
-
- calculate_attachment_wait_time(attachment_count)
- end
-
- def recent_attachment_count
- maximum_wait = (MAX_ATTACHMENT_WAIT_SECONDS + 1).seconds
-
- conversation.messages.incoming
- .joins(:attachments)
- .where(attachments: { created_at: maximum_wait.ago.. })
- .count
- end
-
- def calculate_attachment_wait_time(attachment_count)
- base_wait = 1.second
-
- # Wait longer for more attachments or larger files
- additional_wait = [attachment_count * 1, MAX_ATTACHMENT_WAIT_SECONDS].min.seconds
- base_wait + additional_wait
- end
-
def should_process_captain_response?
- conversation.pending? && message.captain_response_triggering? && inbox.captain_assistant.present? && !inbox.external_bot_active?
+ # Audience and schedule are decided when Captain first takes or reopens a conversation.
+ # Do not re-evaluate an existing pending conversation for each new message.
+ conversation.pending? && message.captain_response_triggering? && captain_assistant_configured? && !inbox.external_bot_active?
end
def perform_handoff
@@ -117,6 +69,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
diff --git a/enterprise/app/validators/captain/audience_validator.rb b/enterprise/app/validators/captain/audience_validator.rb
new file mode 100644
index 000000000..3236e8c1d
--- /dev/null
+++ b/enterprise/app/validators/captain/audience_validator.rb
@@ -0,0 +1,77 @@
+class Captain::AudienceValidator < ActiveModel::Validator
+ GROUP_OPERATORS = %w[and or].freeze
+ VALUELESS_OPERATORS = %w[is_present is_not_present].freeze
+ EQUALITY_OPERATORS = %w[equal_to not_equal_to].freeze
+ CONTAINMENT_OPERATORS = %w[equal_to not_equal_to contains does_not_contain].freeze
+ DATE_OPERATORS = %w[is_greater_than is_less_than days_before].freeze
+ COMPARISON_OPERATORS = %w[equal_to not_equal_to is_present is_not_present is_greater_than is_less_than].freeze
+
+ STANDARD_ATTRIBUTE_OPERATORS = {
+ 'name' => EQUALITY_OPERATORS,
+ 'email' => CONTAINMENT_OPERATORS,
+ 'phone_number' => CONTAINMENT_OPERATORS,
+ 'identifier' => EQUALITY_OPERATORS,
+ 'blocked' => EQUALITY_OPERATORS,
+ 'created_at' => DATE_OPERATORS,
+ 'last_activity_at' => DATE_OPERATORS,
+ 'country_code' => EQUALITY_OPERATORS,
+ 'city' => CONTAINMENT_OPERATORS,
+ 'company_name' => CONTAINMENT_OPERATORS,
+ 'labels' => EQUALITY_OPERATORS,
+ 'hmac_verified' => EQUALITY_OPERATORS,
+ 'browser_language' => EQUALITY_OPERATORS
+ }.freeze
+
+ CUSTOM_ATTRIBUTE_OPERATORS = {
+ 'text' => CONTAINMENT_OPERATORS,
+ 'number' => EQUALITY_OPERATORS,
+ 'currency' => EQUALITY_OPERATORS,
+ 'percent' => EQUALITY_OPERATORS,
+ 'link' => EQUALITY_OPERATORS,
+ 'date' => COMPARISON_OPERATORS,
+ 'list' => EQUALITY_OPERATORS,
+ 'checkbox' => EQUALITY_OPERATORS
+ }.freeze
+
+ def validate(record)
+ audience = record.config['audience']
+ return if audience.blank?
+
+ custom_attribute_types = record.account.custom_attribute_definitions.contact_attribute.each_with_object({}) do |definition, types|
+ types[definition.attribute_key] = definition.attribute_display_type
+ end
+
+ record.errors.add(:config, 'audience must be a valid condition tree') unless valid_node?(audience, 1, custom_attribute_types)
+ end
+
+ private
+
+ def valid_node?(node, depth, custom_attribute_types)
+ return false unless node.is_a?(Hash) && depth <= Captain::AudienceMatcher::MAX_DEPTH
+
+ node = node.with_indifferent_access
+ return valid_group?(node, depth, custom_attribute_types) if node.key?(:conditions)
+
+ valid_leaf?(node, custom_attribute_types)
+ end
+
+ def valid_group?(node, depth, custom_attribute_types)
+ GROUP_OPERATORS.include?(node[:operator].to_s) &&
+ node[:conditions].is_a?(Array) &&
+ node[:conditions].present? &&
+ node[:conditions].all? { |child| valid_node?(child, depth + 1, custom_attribute_types) }
+ end
+
+ def valid_leaf?(node, custom_attribute_types)
+ operator = node[:filter_operator].to_s
+ allowed_operators = allowed_operators(node[:attribute_key].to_s, custom_attribute_types)
+ return false unless allowed_operators&.include?(operator)
+ return true if VALUELESS_OPERATORS.include?(operator)
+
+ node[:values].is_a?(Array) && node[:values].present? && node[:values].all? { |value| value.to_s.present? }
+ end
+
+ def allowed_operators(attribute_key, custom_attribute_types)
+ STANDARD_ATTRIBUTE_OPERATORS[attribute_key] || CUSTOM_ATTRIBUTE_OPERATORS[custom_attribute_types[attribute_key]]
+ 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 f30cda7d0..a539bcc2a 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
@@ -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
diff --git a/spec/enterprise/models/captain/assistant_spec.rb b/spec/enterprise/models/captain/assistant_spec.rb
index e282124ae..177bb631d 100644
--- a/spec/enterprise/models/captain/assistant_spec.rb
+++ b/spec/enterprise/models/captain/assistant_spec.rb
@@ -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)
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..97b4209de
--- /dev/null
+++ b/spec/enterprise/services/captain/audience_matcher_spec.rb
@@ -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
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 bacb403ec..f4974c1b5 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
@@ -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)