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

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

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

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

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

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

## How to test

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

---------

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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