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>
202 lines
6.0 KiB
Ruby
202 lines
6.0 KiB
Ruby
# == Schema Information
|
|
#
|
|
# Table name: captain_assistants
|
|
#
|
|
# id :bigint not null, primary key
|
|
# config :jsonb not null
|
|
# description :text
|
|
# guardrails :jsonb
|
|
# name :string not null
|
|
# response_guidelines :jsonb
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
# account_id :bigint not null
|
|
#
|
|
# Indexes
|
|
#
|
|
# index_captain_assistants_on_account_id (account_id)
|
|
#
|
|
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
|
|
include Concerns::Agentable
|
|
|
|
self.table_name = 'captain_assistants'
|
|
|
|
belongs_to :account
|
|
has_many :documents, class_name: 'Captain::Document', dependent: :destroy_async
|
|
has_many :responses, class_name: 'Captain::AssistantResponse', dependent: :destroy_async
|
|
has_many :faq_suggestions, class_name: 'Captain::FaqSuggestion', dependent: :destroy_async
|
|
has_many :captain_inboxes,
|
|
class_name: 'CaptainInbox',
|
|
foreign_key: :captain_assistant_id,
|
|
dependent: :destroy_async
|
|
has_many :inboxes,
|
|
through: :captain_inboxes
|
|
has_many :messages, as: :sender, dependent: :nullify
|
|
has_many :copilot_threads, dependent: :destroy_async
|
|
has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async
|
|
has_many :agent_sessions, class_name: 'Captain::AgentSession', dependent: :destroy_async
|
|
has_many :conversation_outcomes, dependent: :destroy_async
|
|
|
|
store_accessor :config, :temperature, :feature_faq, :feature_memory, :feature_contact_attributes, :product_name,
|
|
: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) }
|
|
|
|
scope :for_account, ->(account_id) { where(account_id: account_id) }
|
|
|
|
def available_name
|
|
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
|
|
|
|
def inactive_conversation_resolution_disabled?
|
|
auto_resolve_mode == 'disabled'
|
|
end
|
|
|
|
def evaluate_inactive_conversations_before_resolving?
|
|
auto_resolve_mode == 'evaluated'
|
|
end
|
|
|
|
def available_agent_tools
|
|
tools = self.class.built_in_agent_tools.dup
|
|
|
|
custom_tools = account.captain_custom_tools.enabled.map(&:to_tool_metadata)
|
|
tools.concat(custom_tools)
|
|
|
|
tools
|
|
end
|
|
|
|
def available_tool_ids
|
|
available_agent_tools.pluck(:id)
|
|
end
|
|
|
|
def push_event_data
|
|
{
|
|
id: id,
|
|
name: name,
|
|
avatar_url: avatar_url.presence || default_avatar_url,
|
|
description: description,
|
|
created_at: created_at,
|
|
type: 'captain_assistant'
|
|
}
|
|
end
|
|
|
|
def webhook_data
|
|
{
|
|
id: id,
|
|
name: name,
|
|
avatar_url: avatar_url.presence || default_avatar_url,
|
|
description: description,
|
|
created_at: created_at,
|
|
type: 'captain_assistant'
|
|
}
|
|
end
|
|
|
|
def customer_visible_citation_urls(citation_document_ids)
|
|
citation_documents = documents.where(id: citation_document_ids.values).index_by(&:id)
|
|
citation_urls = citation_document_ids.transform_values do |document_id|
|
|
citation_documents[document_id.to_i]&.customer_visible_source_url
|
|
end
|
|
citation_urls.compact.transform_keys(&:to_i)
|
|
end
|
|
|
|
def citations_enabled?
|
|
config['feature_citation']
|
|
end
|
|
|
|
def trusted_citation_urls(run_result)
|
|
return {} unless citations_enabled?
|
|
|
|
citation_document_ids = run_result&.context&.dig(:state, CITATION_SOURCES_STATE_KEY) || {}
|
|
customer_visible_citation_urls(citation_document_ids)
|
|
end
|
|
|
|
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')
|
|
|
|
self.auto_resolve_mode = account&.captain_auto_resolve_mode || 'evaluated'
|
|
end
|
|
|
|
def agent_name
|
|
name.parameterize(separator: '_')
|
|
end
|
|
|
|
def agent_tools
|
|
[
|
|
self.class.resolve_tool_class('faq_lookup').new(self),
|
|
self.class.resolve_tool_class('handoff').new(self),
|
|
*account.captain_custom_tools.enabled.map { |custom_tool| custom_tool.tool(self) }
|
|
]
|
|
end
|
|
|
|
def prompt_context
|
|
{
|
|
name: name,
|
|
description: description,
|
|
product_name: config['product_name'] || 'this product',
|
|
citation_enabled: citations_enabled?,
|
|
scenarios: scenarios.enabled.map do |scenario|
|
|
{
|
|
title: scenario.title,
|
|
key: scenario.handoff_key,
|
|
description: scenario.description
|
|
}
|
|
end,
|
|
response_guidelines: response_guidelines || [],
|
|
guardrails: guardrails || []
|
|
}
|
|
end
|
|
|
|
def default_avatar_url
|
|
"#{ENV.fetch('FRONTEND_URL', nil)}/assets/images/dashboard/captain/logo.svg"
|
|
end
|
|
end
|