Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -0,0 +1,36 @@
The Chatwoot Enterprise license (the “Enterprise License”)
Copyright (c) 2017-2025 Chatwoot Inc
With regard to the Chatwoot Software:
This software and associated documentation files (the "Software") may only be
used in production, if you (and any entity that you represent) have agreed to,
and are in compliance with, the Chatwoot Subscription Terms of Service, available
at https://www.chatwoot.com/terms-of-service/ (the “Enterprise Terms”), or other
agreement governing the use of the Software, as agreed by you and Chatwoot,
and otherwise have a valid Chatwoot Enterprise License for the
correct number of user seats. Subject to the foregoing sentence, you are free to
modify this Software and publish patches to the Software. You agree that Chatwoot
and/or its licensors (as applicable) retain all right, title and interest in and
to all such modifications and/or patches, and all such modifications and/or
patches may only be used, copied, modified, displayed, distributed, or otherwise
exploited with a valid Chatwoot Enterprise subscription for the correct
number of user seats. Notwithstanding the foregoing, you may copy and modify
the Software for development and testing purposes, without requiring a
subscription. You agree that Chatwoot and/or its licensors (as applicable) retain
all right, title and interest in and to all such modifications. You are not
granted any other rights beyond what is expressly stated herein. Subject to the
foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
and/or sell the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
For all third party components incorporated into the Chatwoot Software, those
components are licensed under the original license provided by the owner of the
applicable component.

View File

@@ -0,0 +1,13 @@
module Enterprise::AgentBuilder
def perform
super.tap do |user|
convert_to_saml_provider(user) if user.persisted? && account.saml_enabled?
end
end
private
def convert_to_saml_provider(user)
user.update!(provider: 'saml') unless user.provider == 'saml'
end
end

View File

@@ -0,0 +1,21 @@
module Enterprise::ContactInboxBuilder
private
def generate_source_id
return super unless @inbox.channel_type == 'Channel::Voice'
phone_source_id
end
def phone_source_id
return super unless @inbox.channel_type == 'Channel::Voice'
return SecureRandom.uuid if @contact.phone_number.blank?
@contact.phone_number
end
def allowed_channels?
super || @inbox.channel_type == 'Channel::Voice'
end
end

View File

@@ -0,0 +1,9 @@
module Enterprise::Messages::MessageBuilder
private
def message_type
return @message_type if @message_type == 'incoming' && @conversation.inbox.channel_type == 'Channel::Voice'
super
end
end

View File

@@ -0,0 +1,109 @@
class SamlUserBuilder
def initialize(auth_hash, account_id)
@auth_hash = auth_hash
@account_id = account_id
@saml_settings = AccountSamlSettings.find_by(account_id: account_id)
end
def perform
@user = find_or_create_user
add_user_to_account if @user.persisted?
@user
end
private
def find_or_create_user
user = User.from_email(auth_attribute('email'))
if user
confirm_user_if_required(user)
convert_existing_user_to_saml(user)
return user
end
create_user
end
def confirm_user_if_required(user)
return if user.confirmed?
user.skip_confirmation!
user.save!
end
def convert_existing_user_to_saml(user)
return if user.provider == 'saml'
user.update!(provider: 'saml')
end
def create_user
full_name = [auth_attribute('first_name'), auth_attribute('last_name')].compact.join(' ')
fallback_name = auth_attribute('name') || auth_attribute('email').split('@').first
User.create(
email: auth_attribute('email'),
name: (full_name.presence || fallback_name),
display_name: auth_attribute('first_name'),
provider: 'saml',
uid: uid,
password: SecureRandom.hex(32),
confirmed_at: Time.current
)
end
def add_user_to_account
account = Account.find_by(id: @account_id)
return unless account
# Create account_user if not exists
account_user = AccountUser.find_or_create_by(
user: @user,
account: account
)
# Set default role as agent if not set
account_user.update(role: 'agent') if account_user.role.blank?
# Handle role mappings if configured
apply_role_mappings(account_user, account)
end
def apply_role_mappings(account_user, account)
matching_mapping = find_matching_role_mapping(account)
return unless matching_mapping
if matching_mapping['role']
account_user.update(role: matching_mapping['role'])
elsif matching_mapping['custom_role_id']
account_user.update(custom_role_id: matching_mapping['custom_role_id'])
end
end
def find_matching_role_mapping(_account)
return if @saml_settings&.role_mappings.blank?
saml_groups.each do |group|
mapping = @saml_settings.role_mappings[group]
return mapping if mapping.present?
end
nil
end
def auth_attribute(key, fallback = nil)
@auth_hash.dig('info', key) || fallback
end
def uid
@auth_hash['uid']
end
def saml_groups
# Groups can come from different attributes depending on IdP
@auth_hash.dig('extra', 'raw_info', 'groups') ||
@auth_hash.dig('extra', 'raw_info', 'Group') ||
@auth_hash.dig('extra', 'raw_info', 'memberOf') ||
[]
end
end

View File

@@ -0,0 +1,47 @@
class Api::V1::Accounts::AgentCapacityPolicies::InboxLimitsController < Api::V1::Accounts::EnterpriseAccountsController
before_action -> { check_authorization(AgentCapacityPolicy) }
before_action :fetch_policy
before_action :fetch_inbox, only: [:create]
before_action :fetch_inbox_limit, only: [:update, :destroy]
before_action :validate_no_duplicate, only: [:create]
def create
@inbox_limit = @agent_capacity_policy.inbox_capacity_limits.create!(
inbox: @inbox,
conversation_limit: permitted_params[:conversation_limit]
)
end
def update
@inbox_limit.update!(conversation_limit: permitted_params[:conversation_limit])
end
def destroy
@inbox_limit.destroy!
head :no_content
end
private
def fetch_policy
@agent_capacity_policy = Current.account.agent_capacity_policies.find(params[:agent_capacity_policy_id])
end
def fetch_inbox
@inbox = Current.account.inboxes.find(permitted_params[:inbox_id])
end
def fetch_inbox_limit
@inbox_limit = @agent_capacity_policy.inbox_capacity_limits.find(params[:id])
end
def validate_no_duplicate
return unless @agent_capacity_policy.inbox_capacity_limits.exists?(inbox: @inbox)
render_could_not_create_error(I18n.t('agent_capacity_policy.inbox_already_assigned'))
end
def permitted_params
params.permit(:inbox_id, :conversation_limit)
end
end

View File

@@ -0,0 +1,35 @@
class Api::V1::Accounts::AgentCapacityPolicies::UsersController < Api::V1::Accounts::EnterpriseAccountsController
before_action -> { check_authorization(AgentCapacityPolicy) }
before_action :fetch_policy
before_action :fetch_user, only: [:destroy]
def index
@users = User.joins(:account_users)
.where(account_users: { account_id: Current.account.id, agent_capacity_policy_id: @agent_capacity_policy.id })
end
def create
@account_user = Current.account.account_users.find_by!(user_id: permitted_params[:user_id])
@account_user.update!(agent_capacity_policy: @agent_capacity_policy)
@user = @account_user.user
end
def destroy
@account_user.update!(agent_capacity_policy: nil)
head :ok
end
private
def fetch_policy
@agent_capacity_policy = Current.account.agent_capacity_policies.find(params[:agent_capacity_policy_id])
end
def fetch_user
@account_user = Current.account.account_users.find_by!(user_id: params[:id])
end
def permitted_params
params.permit(:user_id)
end
end

View File

@@ -0,0 +1,37 @@
class Api::V1::Accounts::AgentCapacityPoliciesController < Api::V1::Accounts::EnterpriseAccountsController
before_action :check_authorization
before_action :fetch_policy, only: [:show, :update, :destroy]
def index
@agent_capacity_policies = Current.account.agent_capacity_policies
end
def show; end
def create
@agent_capacity_policy = Current.account.agent_capacity_policies.create!(permitted_params)
end
def update
@agent_capacity_policy.update!(permitted_params)
end
def destroy
@agent_capacity_policy.destroy!
head :ok
end
private
def permitted_params
params.require(:agent_capacity_policy).permit(
:name,
:description,
exclusion_rules: [:exclude_older_than_hours, { excluded_labels: [] }]
)
end
def fetch_policy
@agent_capacity_policy = Current.account.agent_capacity_policies.find(params[:id])
end
end

View File

@@ -0,0 +1,70 @@
class Api::V1::Accounts::AppliedSlasController < Api::V1::Accounts::EnterpriseAccountsController
include Sift
include DateRangeHelper
RESULTS_PER_PAGE = 25
before_action :set_applied_slas, only: [:index, :metrics, :download]
before_action :set_current_page, only: [:index]
before_action :check_admin_authorization?
sort_on :created_at, type: :datetime
def index
@count = number_of_sla_misses
@applied_slas = @missed_applied_slas.page(@current_page).per(RESULTS_PER_PAGE)
end
def metrics
@total_applied_slas = total_applied_slas
@number_of_sla_misses = number_of_sla_misses
@hit_rate = hit_rate
end
def download
@missed_applied_slas = missed_applied_slas
response.headers['Content-Type'] = 'text/csv'
response.headers['Content-Disposition'] = 'attachment; filename=breached_conversation.csv'
render layout: false, formats: [:csv]
end
private
def total_applied_slas
@total_applied_slas ||= @applied_slas.count
end
def number_of_sla_misses
@number_of_sla_misses ||= missed_applied_slas.count
end
def hit_rate
number_of_sla_misses.zero? ? '100%' : "#{hit_rate_percentage}%"
end
def hit_rate_percentage
((total_applied_slas - number_of_sla_misses) / total_applied_slas.to_f * 100).round(2)
end
def set_applied_slas
initial_query = Current.account.applied_slas.includes(:conversation)
@applied_slas = apply_filters(initial_query)
end
def apply_filters(query)
query.filter_by_date_range(range)
.filter_by_inbox_id(params[:inbox_id])
.filter_by_team_id(params[:team_id])
.filter_by_sla_policy_id(params[:sla_policy_id])
.filter_by_label_list(params[:label_list])
.filter_by_assigned_agent_id(params[:assigned_agent_id])
end
def missed_applied_slas
@missed_applied_slas ||= @applied_slas.missed
end
def set_current_page
@current_page = params[:page] || 1
end
end

View File

@@ -0,0 +1,30 @@
class Api::V1::Accounts::AuditLogsController < Api::V1::Accounts::EnterpriseAccountsController
before_action :check_admin_authorization?
before_action :fetch_audit
RESULTS_PER_PAGE = 15
def show
@audit_logs = @audit_logs.page(params[:page]).per(RESULTS_PER_PAGE)
@current_page = @audit_logs.current_page
@total_entries = @audit_logs.total_count
@per_page = RESULTS_PER_PAGE
end
private
def fetch_audit
@audit_logs = if audit_logs_enabled?
Current.account.associated_audits.order(created_at: :desc)
else
Current.account.associated_audits.none
end
return if audit_logs_enabled?
Rails.logger.warn("Audit logs are disabled for account #{Current.account.id}")
end
def audit_logs_enabled?
Current.account.feature_enabled?(:audit_logs)
end
end

View File

@@ -0,0 +1,88 @@
class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_current_page, only: [:index]
before_action :set_assistant, only: [:create]
before_action :set_responses, except: [:create]
before_action :set_response, only: [:show, :update, :destroy]
RESULTS_PER_PAGE = 25
def index
filtered_query = apply_filters(@responses)
@responses_count = filtered_query.count
@responses = filtered_query.page(@current_page).per(RESULTS_PER_PAGE)
end
def show; end
def create
@response = Current.account.captain_assistant_responses.new(response_params)
@response.documentable = Current.user
@response.save!
end
def update
@response.update!(response_params)
end
def destroy
@response.destroy
head :no_content
end
private
def apply_filters(base_query)
base_query = base_query.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present?
if permitted_params[:document_id].present?
base_query = base_query.where(
documentable_id: permitted_params[:document_id],
documentable_type: 'Captain::Document'
)
end
base_query = base_query.where(status: permitted_params[:status]) if permitted_params[:status].present?
if permitted_params[:search].present?
search_term = "%#{permitted_params[:search]}%"
base_query = base_query.where(
'question ILIKE :search OR answer ILIKE :search',
search: search_term
)
end
base_query
end
def set_assistant
@assistant = Current.account.captain_assistants.find_by(id: params[:assistant_id])
end
def set_responses
@responses = Current.account.captain_assistant_responses.includes(:assistant, :documentable).ordered
end
def set_response
@response = @responses.find(permitted_params[:id])
end
def set_current_page
@current_page = permitted_params[:page] || 1
end
def permitted_params
params.permit(:id, :assistant_id, :page, :document_id, :account_id, :status, :search)
end
def response_params
params.require(:assistant_response).permit(
:question,
:answer,
:assistant_id,
:status
)
end
end

View File

@@ -0,0 +1,73 @@
class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_assistant, only: [:show, :update, :destroy, :playground]
def index
@assistants = account_assistants.ordered
end
def show; end
def create
@assistant = account_assistants.create!(assistant_params)
end
def update
@assistant.update!(assistant_params)
end
def destroy
@assistant.destroy
head :no_content
end
def playground
response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
additional_message: params[:message_content],
message_history: message_history
)
render json: response
end
def tools
assistant = Captain::Assistant.new(account: Current.account)
@tools = assistant.available_agent_tools
end
private
def set_assistant
@assistant = account_assistants.find(params[:id])
end
def account_assistants
@account_assistants ||= Captain::Assistant.for_account(Current.account.id)
end
def assistant_params
permitted = params.require(:assistant).permit(:name, :description,
config: [
:product_name, :feature_faq, :feature_memory, :feature_citation,
:welcome_message, :handoff_message, :resolution_message,
:instructions, :temperature
])
# Handle array parameters separately to allow partial updates
permitted[:response_guidelines] = params[:assistant][:response_guidelines] if params[:assistant].key?(:response_guidelines)
permitted[:guardrails] = params[:assistant][:guardrails] if params[:assistant].key?(:guardrails)
permitted
end
def playground_params
params.require(:assistant).permit(:message_content, message_history: [:role, :content])
end
def message_history
(playground_params[:message_history] || []).map { |message| { role: message[:role], content: message[:content] } }
end
end

View File

@@ -0,0 +1,51 @@
class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :validate_params
before_action :type_matches?
MODEL_TYPE = ['AssistantResponse'].freeze
def create
@responses = process_bulk_action
end
private
def validate_params
return if params[:type].present? && params[:ids].present? && params[:fields].present?
render json: { success: false }, status: :unprocessable_entity
end
def type_matches?
return if MODEL_TYPE.include?(params[:type])
render json: { success: false }, status: :unprocessable_entity
end
def process_bulk_action
case params[:type]
when 'AssistantResponse'
handle_assistant_responses
end
end
def handle_assistant_responses
responses = Current.account.captain_assistant_responses.where(id: params[:ids])
return unless responses.exists?
case params[:fields][:status]
when 'approve'
responses.pending.update(status: 'approved')
responses
when 'delete'
responses.destroy_all
[]
end
end
def permitted_params
params.permit(:type, ids: [], fields: [:status])
end
end

View File

@@ -0,0 +1,33 @@
class Api::V1::Accounts::Captain::CopilotMessagesController < Api::V1::Accounts::BaseController
before_action :set_copilot_thread
def index
@copilot_messages = @copilot_thread
.copilot_messages
.includes(:copilot_thread)
.order(created_at: :asc)
.page(permitted_params[:page] || 1)
.per(1000)
end
def create
@copilot_message = @copilot_thread.copilot_messages.create!(
message: { content: params[:message] },
message_type: :user
)
@copilot_message.enqueue_response_job(params[:conversation_id], Current.user.id)
end
private
def set_copilot_thread
@copilot_thread = Current.account.copilot_threads.find_by!(
id: params[:copilot_thread_id],
user: Current.user
)
end
def permitted_params
params.permit(:page)
end
end

View File

@@ -0,0 +1,58 @@
class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::BaseController
before_action :ensure_message, only: :create
def index
@copilot_threads = Current.account.copilot_threads
.where(user_id: Current.user.id)
.includes(:user, :assistant)
.order(created_at: :desc)
.page(permitted_params[:page] || 1)
.per(5)
end
def create
ActiveRecord::Base.transaction do
@copilot_thread = Current.account.copilot_threads.create!(
title: copilot_thread_params[:message],
user: Current.user,
assistant: assistant
)
copilot_message = @copilot_thread.copilot_messages.create!(
message_type: :user,
message: { content: copilot_thread_params[:message] }
)
build_copilot_response(copilot_message)
end
end
private
def build_copilot_response(copilot_message)
if Current.account.usage_limits[:captain][:responses][:current_available].positive?
copilot_message.enqueue_response_job(copilot_thread_params[:conversation_id], Current.user.id)
else
copilot_message.copilot_thread.copilot_messages.create!(
message_type: :assistant,
message: { content: I18n.t('captain.copilot_limit') }
)
end
end
def ensure_message
return render_could_not_create_error(I18n.t('captain.copilot_message_required')) if copilot_thread_params[:message].blank?
end
def assistant
Current.account.captain_assistants.find(copilot_thread_params[:assistant_id])
end
def copilot_thread_params
params.permit(:message, :assistant_id, :conversation_id)
end
def permitted_params
params.permit(:page)
end
end

View File

@@ -0,0 +1,49 @@
class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::CustomTool) }
before_action :set_custom_tool, only: [:show, :update, :destroy]
def index
@custom_tools = account_custom_tools.enabled
end
def show; end
def create
@custom_tool = account_custom_tools.create!(custom_tool_params)
end
def update
@custom_tool.update!(custom_tool_params)
end
def destroy
@custom_tool.destroy
head :no_content
end
private
def set_custom_tool
@custom_tool = account_custom_tools.find(params[:id])
end
def account_custom_tools
@account_custom_tools ||= Current.account.captain_custom_tools
end
def custom_tool_params
params.require(:custom_tool).permit(
:title,
:description,
:endpoint_url,
:http_method,
:request_template,
:response_template,
:auth_type,
:enabled,
auth_config: {},
param_schema: [:name, :type, :description, :required]
)
end
end

View File

@@ -0,0 +1,62 @@
class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_current_page, only: [:index]
before_action :set_documents, except: [:create]
before_action :set_document, only: [:show, :destroy]
before_action :set_assistant, only: [:create]
RESULTS_PER_PAGE = 25
def index
base_query = @documents
base_query = base_query.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present?
@documents_count = base_query.count
@documents = base_query.page(@current_page).per(RESULTS_PER_PAGE)
end
def show; end
def create
return render_could_not_create_error('Missing Assistant') if @assistant.nil?
@document = @assistant.documents.build(document_params)
@document.save!
rescue Captain::Document::LimitExceededError => e
render_could_not_create_error(e.message)
rescue ActiveRecord::RecordInvalid => e
render_could_not_create_error(e.record.errors.full_messages.join(', '))
end
def destroy
@document.destroy
head :no_content
end
private
def set_documents
@documents = Current.account.captain_documents.includes(:assistant).ordered
end
def set_document
@document = @documents.find(permitted_params[:id])
end
def set_assistant
@assistant = Current.account.captain_assistants.find_by(id: document_params[:assistant_id])
end
def set_current_page
@current_page = permitted_params[:page] || 1
end
def permitted_params
params.permit(:assistant_id, :page, :id, :account_id)
end
def document_params
params.require(:document).permit(:name, :external_link, :assistant_id, :pdf_file)
end
end

View File

@@ -0,0 +1,39 @@
class Api::V1::Accounts::Captain::InboxesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_assistant
def index
@inboxes = @assistant.inboxes
end
def create
inbox = Current.account.inboxes.find(assistant_params[:inbox_id])
@captain_inbox = @assistant.captain_inboxes.build(inbox: inbox)
@captain_inbox.save!
end
def destroy
@captain_inbox = @assistant.captain_inboxes.find_by!(inbox_id: permitted_params[:inbox_id])
@captain_inbox.destroy!
head :no_content
end
private
def set_assistant
@assistant = account_assistants.find(permitted_params[:assistant_id])
end
def account_assistants
@account_assistants ||= Current.account.captain_assistants
end
def permitted_params
params.permit(:assistant_id, :id, :account_id, :inbox_id)
end
def assistant_params
params.require(:inbox).permit(:inbox_id)
end
end

View File

@@ -0,0 +1,47 @@
class Api::V1::Accounts::Captain::ScenariosController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Scenario) }
before_action :set_assistant
before_action :set_scenario, only: [:show, :update, :destroy]
def index
@scenarios = assistant_scenarios.enabled
end
def show; end
def create
@scenario = assistant_scenarios.create!(scenario_params.merge(account: Current.account))
end
def update
@scenario.update!(scenario_params)
end
def destroy
@scenario.destroy
head :no_content
end
private
def set_assistant
@assistant = account_assistants.find(params[:assistant_id])
end
def account_assistants
@account_assistants ||= Current.account.captain_assistants
end
def set_scenario
@scenario = assistant_scenarios.find(params[:id])
end
def assistant_scenarios
@assistant.scenarios
end
def scenario_params
params.require(:scenario).permit(:title, :description, :instruction, :enabled, tools: [])
end
end

View File

@@ -0,0 +1,71 @@
class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseController
before_action :check_authorization
def rewrite
result = Captain::RewriteService.new(
account: Current.account,
content: params[:content],
operation: params[:operation],
conversation_display_id: params[:conversation_display_id]
).perform
render_result(result)
end
def summarize
result = Captain::SummaryService.new(
account: Current.account,
conversation_display_id: params[:conversation_display_id]
).perform
render_result(result)
end
def reply_suggestion
result = Captain::ReplySuggestionService.new(
account: Current.account,
conversation_display_id: params[:conversation_display_id],
user: Current.user
).perform
render_result(result)
end
def label_suggestion
result = Captain::LabelSuggestionService.new(
account: Current.account,
conversation_display_id: params[:conversation_display_id]
).perform
render_result(result)
end
def follow_up
result = Captain::FollowUpService.new(
account: Current.account,
follow_up_context: params[:follow_up_context]&.to_unsafe_h,
user_message: params[:message],
conversation_display_id: params[:conversation_display_id]
).perform
render_result(result)
end
private
def render_result(result)
if result.nil?
render json: { message: nil }
elsif result[:error]
render json: { error: result[:error] }, status: :unprocessable_entity
else
response_data = { message: result[:message] }
response_data[:follow_up_context] = result[:follow_up_context] if result[:follow_up_context]
render json: response_data
end
end
def check_authorization
authorize(:'captain/tasks')
end
end

View File

@@ -0,0 +1,75 @@
class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAccountsController
include Sift
sort_on :name, type: :string
sort_on :domain, type: :string
sort_on :created_at, type: :datetime
sort_on :contacts_count, internal_name: :order_on_contacts_count, type: :scope, scope_params: [:direction]
RESULTS_PER_PAGE = 25
before_action :check_authorization
before_action :set_current_page, only: [:index, :search]
before_action :fetch_company, only: [:show, :update, :destroy]
def index
@companies = fetch_companies(resolved_companies)
@companies_count = @companies.total_count
end
def search
if params[:q].blank?
return render json: { error: I18n.t('errors.companies.search.query_missing') },
status: :unprocessable_entity
end
companies = resolved_companies.search_by_name_or_domain(params[:q])
@companies = fetch_companies(companies)
@companies_count = @companies.total_count
end
def show; end
def create
@company = Current.account.companies.build(company_params)
@company.save!
end
def update
@company.update!(company_params)
end
def destroy
@company.destroy!
head :ok
end
private
def resolved_companies
@resolved_companies ||= Current.account.companies
end
def set_current_page
@current_page = params[:page] || 1
end
def fetch_companies(companies)
filtrate(companies)
.page(@current_page)
.per(RESULTS_PER_PAGE)
end
def check_authorization
raise Pundit::NotAuthorizedError unless ChatwootApp.enterprise?
authorize(Company)
end
def fetch_company
@company = Current.account.companies.find(params[:id])
end
def company_params
params.require(:company).permit(:name, :domain, :description, :avatar)
end
end

View File

@@ -0,0 +1,58 @@
class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseController
before_action :set_voice_inbox_for_conference
def token
render json: Voice::Provider::Twilio::TokenService.new(
inbox: @voice_inbox,
user: Current.user,
account: Current.account
).generate
end
def create
conversation = fetch_conversation_by_display_id
ensure_call_sid!(conversation)
conference_service = Voice::Provider::Twilio::ConferenceService.new(conversation: conversation)
conference_sid = conference_service.ensure_conference_sid
conference_service.mark_agent_joined(user: current_user)
render json: {
status: 'success',
id: conversation.display_id,
conference_sid: conference_sid,
using_webrtc: true
}
end
def destroy
conversation = fetch_conversation_by_display_id
Voice::Provider::Twilio::ConferenceService.new(conversation: conversation).end_conference
render json: { status: 'success', id: conversation.display_id }
end
private
def ensure_call_sid!(conversation)
return conversation.identifier if conversation.identifier.present?
incoming_sid = params.require(:call_sid)
conversation.update!(identifier: incoming_sid)
incoming_sid
end
def set_voice_inbox_for_conference
@voice_inbox = Current.account.inboxes.find(params[:inbox_id])
authorize @voice_inbox, :show?
end
def fetch_conversation_by_display_id
cid = params[:conversation_id]
raise ActiveRecord::RecordNotFound, 'conversation_id required' if cid.blank?
conversation = @voice_inbox.conversations.find_by!(display_id: cid)
authorize conversation, :show?
conversation
end
end

View File

@@ -0,0 +1,38 @@
class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseController
before_action :contact
before_action :voice_inbox
def create
authorize contact, :show?
authorize voice_inbox, :show?
result = Voice::OutboundCallBuilder.perform!(
account: Current.account,
inbox: voice_inbox,
user: Current.user,
contact: contact
)
conversation = result[:conversation]
render json: {
conversation_id: conversation.display_id,
inbox_id: voice_inbox.id,
call_sid: result[:call_sid],
conference_sid: conversation.additional_attributes['conference_sid']
}
end
private
def contact
@contact ||= Current.account.contacts.find(params[:id])
end
def voice_inbox
@voice_inbox ||= Current.user.assigned_inboxes.where(
account_id: Current.account.id,
channel_type: 'Channel::Voice'
).find(params.require(:inbox_id))
end
end

View File

@@ -0,0 +1,31 @@
class Api::V1::Accounts::CustomRolesController < Api::V1::Accounts::EnterpriseAccountsController
before_action :fetch_custom_role, only: [:show, :update, :destroy]
before_action :check_authorization
def index
@custom_roles = Current.account.custom_roles
end
def show; end
def create
@custom_role = Current.account.custom_roles.create!(permitted_params)
end
def update
@custom_role.update!(permitted_params)
end
def destroy
@custom_role.destroy!
head :ok
end
def permitted_params
params.require(:custom_role).permit(:name, :description, permissions: [])
end
def fetch_custom_role
@custom_role = Current.account.custom_roles.find_by(id: params[:id])
end
end

View File

@@ -0,0 +1,2 @@
class Api::V1::Accounts::EnterpriseAccountsController < Api::V1::Accounts::BaseController
end

View File

@@ -0,0 +1,30 @@
class Api::V1::Accounts::ReportingEventsController < Api::V1::Accounts::EnterpriseAccountsController
include DateRangeHelper
RESULTS_PER_PAGE = 25
before_action :check_admin_authorization?
before_action :set_reporting_events, only: [:index]
before_action :set_current_page, only: [:index]
def index
@reporting_events = @reporting_events.page(@current_page).per(RESULTS_PER_PAGE)
@total_count = @reporting_events.total_count
end
private
def set_reporting_events
@reporting_events = Current.account.reporting_events
.includes(:conversation, :user, :inbox)
.filter_by_date_range(range)
.filter_by_inbox_id(params[:inbox_id])
.filter_by_user_id(params[:user_id])
.filter_by_name(params[:name])
.order(created_at: :desc)
end
def set_current_page
@current_page = (params[:page] || 1).to_i
end
end

View File

@@ -0,0 +1,63 @@
class Api::V1::Accounts::SamlSettingsController < Api::V1::Accounts::BaseController
before_action :check_saml_sso_enabled
before_action :check_saml_feature_enabled
before_action :check_authorization
before_action :set_saml_settings
def show; end
def create
@saml_settings = Current.account.build_saml_settings(saml_settings_params)
if @saml_settings.save
render :show
else
render json: { errors: @saml_settings.errors.full_messages }, status: :unprocessable_entity
end
end
def update
if @saml_settings.update(saml_settings_params)
render :show
else
render json: { errors: @saml_settings.errors.full_messages }, status: :unprocessable_entity
end
end
def destroy
@saml_settings.destroy!
head :no_content
end
private
def set_saml_settings
@saml_settings = Current.account.saml_settings ||
Current.account.build_saml_settings
end
def saml_settings_params
params.require(:saml_settings).permit(
:sso_url,
:certificate,
:idp_entity_id,
:sp_entity_id,
role_mappings: {}
)
end
def check_authorization
authorize(AccountSamlSettings)
end
def check_saml_feature_enabled
return if Current.account.feature_enabled?('saml')
render json: { error: I18n.t('errors.saml.feature_not_enabled') }, status: :forbidden
end
def check_saml_sso_enabled
return if GlobalConfigService.load('ENABLE_SAML_SSO_LOGIN', 'true').to_s == 'true'
render json: { error: I18n.t('errors.saml.sso_not_enabled') }, status: :forbidden
end
end

View File

@@ -0,0 +1,32 @@
class Api::V1::Accounts::SlaPoliciesController < Api::V1::Accounts::EnterpriseAccountsController
before_action :fetch_sla, only: [:show, :update, :destroy]
before_action :check_authorization
def index
@sla_policies = Current.account.sla_policies
end
def show; end
def create
@sla_policy = Current.account.sla_policies.create!(permitted_params)
end
def update
@sla_policy.update!(permitted_params)
end
def destroy
::DeleteObjectJob.perform_later(@sla_policy, Current.user, request.ip) if @sla_policy.present?
head :ok
end
def permitted_params
params.require(:sla_policy).permit(:name, :description, :first_response_time_threshold, :next_response_time_threshold,
:resolution_time_threshold, :only_during_business_hours)
end
def fetch_sla
@sla_policy = Current.account.sla_policies.find_by(id: params[:id])
end
end

View File

@@ -0,0 +1,81 @@
class Api::V1::AuthController < Api::BaseController
skip_before_action :authenticate_user!, only: [:saml_login]
before_action :find_user_and_account, only: [:saml_login]
def saml_login
unless saml_sso_enabled?
render json: { error: 'SAML SSO login is not enabled' }, status: :forbidden
return
end
return if @account.nil?
relay_state = params[:target] || 'web'
saml_initiation_url = "/auth/saml?account_id=#{@account.id}&RelayState=#{relay_state}"
redirect_to saml_initiation_url, status: :temporary_redirect
end
private
def find_user_and_account
return unless validate_email_presence
find_saml_enabled_account
end
def validate_email_presence
@email = params[:email]&.downcase&.strip
return true if @email.present?
render json: { error: I18n.t('auth.saml.invalid_email') }, status: :bad_request
false
end
def find_saml_enabled_account
user = User.from_email(@email)
return render_saml_error unless user
account_user = find_account_with_saml(user)
return render_saml_error unless account_user
@account = account_user.account
end
def find_account_with_saml(user)
user.account_users
.joins(account: :saml_settings)
.where.not(saml_settings: { sso_url: [nil, ''] })
.where.not(saml_settings: { certificate: [nil, ''] })
.find { |account_user| account_user.account.feature_enabled?('saml') }
end
def render_saml_error
error = 'saml-authentication-failed'
if mobile_target?
mobile_deep_link_base = GlobalConfigService.load('MOBILE_DEEP_LINK_BASE', 'chatwootapp')
redirect_to "#{mobile_deep_link_base}://auth/saml?error=#{ERB::Util.url_encode(error)}", allow_other_host: true
else
redirect_to sso_login_page_url(error: error)
end
end
def mobile_target?
params[:target]&.casecmp('mobile')&.zero?
end
def sso_login_page_url(error: nil)
frontend_url = ENV.fetch('FRONTEND_URL', nil)
params = { error: error }.compact
query = params.to_query
query_fragment = query.present? ? "?#{query}" : ''
"#{frontend_url}/app/login/sso#{query_fragment}"
end
def saml_sso_enabled?
GlobalConfigService.load('ENABLE_SAML_SSO_LOGIN', 'true').to_s == 'true'
end
end

View File

@@ -0,0 +1,22 @@
class CustomDomainsController < ApplicationController
def verify
challenge_id = permitted_params[:id]
domain = request.host
portal = Portal.find_by(custom_domain: domain)
return render plain: 'Domain not found', status: :not_found unless portal
ssl_settings = portal.ssl_settings || {}
return render plain: 'Challenge ID not found', status: :not_found unless ssl_settings['cf_verification_id'] == challenge_id
render plain: ssl_settings['cf_verification_body'], status: :ok
end
private
def permitted_params
params.permit(:id)
end
end

View File

@@ -0,0 +1,17 @@
module Enterprise::Api::V1::Accounts::AgentsController
def create
super
associate_agent_with_custom_role
end
def update
super
associate_agent_with_custom_role
end
private
def associate_agent_with_custom_role
@agent.current_account_user.update!(custom_role_id: params[:custom_role_id])
end
end

View File

@@ -0,0 +1,27 @@
module Enterprise::Api::V1::Accounts::ConversationsController
extend ActiveSupport::Concern
def inbox_assistant
assistant = @conversation.inbox.captain_assistant
if assistant
render json: { assistant: { id: assistant.id, name: assistant.name } }
else
render json: { assistant: nil }
end
end
def reporting_events
@reporting_events = @conversation.reporting_events.order(created_at: :asc)
end
def permitted_update_params
super.merge(params.permit(:sla_policy_id))
end
private
def copilot_params
params.permit(:previous_history, :message, :assistant_id)
end
end

View File

@@ -0,0 +1,12 @@
module Enterprise::Api::V1::Accounts::CsatSurveyResponsesController
def update
@csat_survey_response = Current.account.csat_survey_responses.find(params[:id])
authorize @csat_survey_response
@csat_survey_response.update!(
csat_review_notes: params[:csat_review_notes],
review_notes_updated_by: Current.user,
review_notes_updated_at: Time.current
)
end
end

View File

@@ -0,0 +1,33 @@
module Enterprise::Api::V1::Accounts::InboxesController
def inbox_attributes
super + ee_inbox_attributes
end
def ee_inbox_attributes
[auto_assignment_config: [:max_assignment_limit]]
end
private
def allowed_channel_types
super + ['voice']
end
def channel_type_from_params
case permitted_params[:channel][:type]
when 'voice'
Channel::Voice
else
super
end
end
def account_channels_method
case permitted_params[:channel][:type]
when 'voice'
Current.account.voice_channels
else
super
end
end
end

View File

@@ -0,0 +1,15 @@
module Enterprise::Api::V1::Accounts::PortalsController
def ssl_status
return render_could_not_create_error(I18n.t('portals.ssl_status.custom_domain_not_configured')) if @portal.custom_domain.blank?
result = Cloudflare::CheckCustomHostnameService.new(portal: @portal).perform
return render_could_not_create_error(result[:errors]) if result[:errors].present?
ssl_settings = @portal.ssl_settings || {}
render json: {
status: ssl_settings['cf_status'],
verification_errors: ssl_settings['cf_verification_errors']
}
end
end

View File

@@ -0,0 +1,147 @@
class Enterprise::Api::V1::AccountsController < Api::BaseController
include BillingHelper
before_action :fetch_account
before_action :check_authorization
before_action :check_cloud_env, only: [:limits, :toggle_deletion]
def subscription
if stripe_customer_id.blank? && @account.custom_attributes['is_creating_customer'].blank?
@account.update(custom_attributes: { is_creating_customer: true })
Enterprise::CreateStripeCustomerJob.perform_later(@account)
end
head :no_content
end
def limits
limits = if default_plan?(@account)
{
'conversation' => {
'allowed' => 500,
'consumed' => conversations_this_month(@account)
},
'non_web_inboxes' => {
'allowed' => 0,
'consumed' => non_web_inboxes(@account)
},
'agents' => {
'allowed' => 2,
'consumed' => agents(@account)
}
}
else
default_limits
end
# include id in response to ensure that the store can be updated on the frontend
render json: { id: @account.id, limits: limits }, status: :ok
end
def checkout
return create_stripe_billing_session(stripe_customer_id) if stripe_customer_id.present?
render_invalid_billing_details
end
def toggle_deletion
action_type = params[:action_type]
case action_type
when 'delete'
mark_for_deletion
when 'undelete'
unmark_for_deletion
else
render json: { error: 'Invalid action_type. Must be either "delete" or "undelete"' }, status: :unprocessable_entity
end
end
def topup_checkout
return render json: { error: I18n.t('errors.topup.credits_required') }, status: :unprocessable_entity if params[:credits].blank?
service = Enterprise::Billing::TopupCheckoutService.new(account: @account)
result = service.create_checkout_session(credits: params[:credits].to_i)
@account.reload
render json: result.merge(
id: @account.id,
limits: @account.limits,
custom_attributes: @account.custom_attributes
)
rescue Enterprise::Billing::TopupCheckoutService::Error, Stripe::StripeError => e
render_could_not_create_error(e.message)
end
private
def check_cloud_env
render json: { error: 'Not found' }, status: :not_found unless ChatwootApp.chatwoot_cloud?
end
def default_limits
{
'conversation' => {},
'non_web_inboxes' => {},
'agents' => {
'allowed' => @account.usage_limits[:agents],
'consumed' => agents(@account)
},
'captain' => @account.usage_limits[:captain]
}
end
def fetch_account
@account = current_user.accounts.find(params[:id])
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
end
def stripe_customer_id
@account.custom_attributes['stripe_customer_id']
end
def mark_for_deletion
reason = 'manual_deletion'
if @account.mark_for_deletion(reason)
cancel_cloud_subscriptions_for_deletion
render json: { message: 'Account marked for deletion' }, status: :ok
else
render json: { message: @account.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
def unmark_for_deletion
if @account.unmark_for_deletion
render json: { message: 'Account unmarked for deletion' }, status: :ok
else
render json: { message: @account.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
def render_invalid_billing_details
render_could_not_create_error('Please subscribe to a plan before viewing the billing details')
end
def create_stripe_billing_session(customer_id)
session = Enterprise::Billing::CreateSessionService.new.create_session(customer_id)
render_redirect_url(session.url)
end
def cancel_cloud_subscriptions_for_deletion
Enterprise::Billing::CancelCloudSubscriptionsService.new(account: @account).perform
rescue Stripe::StripeError => e
Rails.logger.warn("Failed to cancel cloud subscriptions for account #{@account.id}: #{e.class} - #{e.message}")
end
def render_redirect_url(redirect_url)
render json: { redirect_url: redirect_url }
end
def pundit_user
{
user: current_user,
account: @account,
account_user: @current_account_user
}
end
end

View File

@@ -0,0 +1,7 @@
module Enterprise::Api::V1::AccountsSettings
private
def permitted_settings_attributes
super + [{ conversation_required_attributes: [] }]
end
end

View File

@@ -0,0 +1,40 @@
module Enterprise::Api::V2::AccountsController
private
def fetch_account_and_user_info
@data = fetch_from_clearbit
return if @data.blank?
update_user_info
end
def fetch_from_clearbit
Enterprise::ClearbitLookupService.lookup(@user.email)
rescue StandardError => e
Rails.logger.error "Error fetching data from clearbit: #{e}"
nil
end
def update_user_info
@user.update!(name: @data[:name]) if @data[:name].present?
end
def data_from_clearbit
return {} if @data.blank?
{ name: @data[:company_name],
custom_attributes: {
'industry' => @data[:industry],
'company_size' => @data[:company_size],
'timezone' => @data[:timezone],
'logo' => @data[:logo]
} }
end
def account_attributes
super.deep_merge(
data_from_clearbit
)
end
end

View File

@@ -0,0 +1,3 @@
module Enterprise::Concerns::ApplicationControllerConcern
extend ActiveSupport::Concern
end

View File

@@ -0,0 +1,100 @@
module Enterprise::DeviseOverrides::OmniauthCallbacksController
def saml
# Call parent's omniauth_success which handles the auth
omniauth_success
end
def redirect_callbacks
# derive target redirect route from 'resource_class' param, which was set
# before authentication.
devise_mapping = get_devise_mapping
redirect_route = get_redirect_route(devise_mapping)
# preserve omniauth info for success route. ignore 'extra' in twitter
# auth response to avoid CookieOverflow.
session['dta.omniauth.auth'] = request.env['omniauth.auth'].except('extra')
session['dta.omniauth.params'] = request.env['omniauth.params']
# For SAML, use 303 See Other to convert POST to GET and preserve session
if params[:provider] == 'saml'
redirect_to redirect_route, { status: 303 }.merge(redirect_options)
else
super
end
end
def omniauth_success
case auth_hash&.dig('provider')
when 'saml'
handle_saml_auth
else
super
end
end
def omniauth_failure
return super unless params[:provider] == 'saml'
relay_state = saml_relay_state
error = params[:message] || 'authentication-failed'
if for_mobile?(relay_state)
redirect_to_mobile_error(error, relay_state)
else
redirect_to login_page_url(error: "saml-#{error}")
end
end
private
def handle_saml_auth
account_id = extract_saml_account_id
relay_state = saml_relay_state
unless saml_enabled_for_account?(account_id)
return redirect_to_mobile_error('saml-not-enabled') if for_mobile?(relay_state)
return redirect_to login_page_url(error: 'saml-not-enabled')
end
@resource = SamlUserBuilder.new(auth_hash, account_id).perform
if @resource.persisted?
return sign_in_user_on_mobile if for_mobile?(relay_state)
sign_in_user
else
return redirect_to_mobile_error('saml-authentication-failed') if for_mobile?(relay_state)
redirect_to login_page_url(error: 'saml-authentication-failed')
end
end
def extract_saml_account_id
params[:account_id] || session[:saml_account_id] || request.env['omniauth.params']&.dig('account_id')
end
def saml_relay_state
session[:saml_relay_state] || request.env['omniauth.params']&.dig('RelayState')
end
def for_mobile?(relay_state)
relay_state.to_s.casecmp('mobile').zero?
end
def redirect_to_mobile_error(error)
mobile_deep_link_base = GlobalConfigService.load('MOBILE_DEEP_LINK_BASE', 'chatwootapp')
redirect_to "#{mobile_deep_link_base}://auth/saml?error=#{ERB::Util.url_encode(error)}", allow_other_host: true
end
def saml_enabled_for_account?(account_id)
return false if account_id.blank?
account = Account.find_by(id: account_id)
return false if account.nil?
return false unless account.feature_enabled?('saml')
AccountSamlSettings.find_by(account_id: account_id).present?
end
end

View File

@@ -0,0 +1,16 @@
module Enterprise::DeviseOverrides::PasswordsController
include SamlAuthenticationHelper
def create
if saml_user_attempting_password_auth?(params[:email])
render json: {
success: false,
message: I18n.t('messages.reset_password_saml_user'),
errors: [I18n.t('messages.reset_password_saml_user')]
}, status: :forbidden
return
end
super
end
end

View File

@@ -0,0 +1,40 @@
module Enterprise::DeviseOverrides::SessionsController
include SamlAuthenticationHelper
def create
if saml_user_attempting_password_auth?(params[:email], sso_auth_token: params[:sso_auth_token])
render json: {
success: false,
message: I18n.t('messages.login_saml_user'),
errors: [I18n.t('messages.login_saml_user')]
}, status: :unauthorized
return
end
super
end
def render_create_success
create_audit_event('sign_in')
super
end
def destroy
create_audit_event('sign_out')
super
end
def create_audit_event(action)
return unless @resource
associated_type = 'Account'
@resource.accounts.each do |account|
@resource.audits.create(
action: action,
user_id: @resource.id,
associated_id: account.id,
associated_type: associated_type
)
end
end
end

View File

@@ -0,0 +1,11 @@
module Enterprise::Public::Api::V1::Portals::ArticlesController
private
def search_articles
if @portal.account.feature_enabled?('help_center_embedding_search')
@articles = @articles.vector_search(list_params.merge(account_id: @portal.account_id)) if list_params[:query].present?
else
super
end
end
end

View File

@@ -0,0 +1,15 @@
module Enterprise::SuperAdmin::AccountsController
def update
# Handle manually managed features from form submission
if params[:account] && params[:account][:manually_managed_features].present?
# Update using the service - it will handle array conversion and validation
service = ::Internal::Accounts::InternalAttributesService.new(requested_resource)
service.manually_managed_features = params[:account][:manually_managed_features]
# Remove the manually_managed_features from params to prevent ActiveModel::UnknownAttributeError
params[:account].delete(:manually_managed_features)
end
super
end
end

View File

@@ -0,0 +1,56 @@
module Enterprise::SuperAdmin::AppConfigsController
private
def allowed_configs
return super if ChatwootHub.pricing_plan == 'community'
case @config
when 'custom_branding'
@allowed_configs = custom_branding_options
when 'internal'
@allowed_configs = internal_config_options
when 'captain'
@allowed_configs = captain_config_options
when 'saml'
@allowed_configs = saml_config_options
else
super
end
end
def custom_branding_options
%w[
LOGO_THUMBNAIL
LOGO
LOGO_DARK
BRAND_NAME
INSTALLATION_NAME
BRAND_URL
WIDGET_BRAND_URL
TERMS_URL
PRIVACY_URL
DISPLAY_MANIFEST
]
end
def internal_config_options
%w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY CLOUD_ANALYTICS_TOKEN CLEARBIT_API_KEY DASHBOARD_SCRIPTS INACTIVE_WHATSAPP_NUMBERS
SKIP_INCOMING_BCC_PROCESSING CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL CHATWOOT_INSTANCE_ADMIN_EMAIL
OG_IMAGE_CDN_URL OG_IMAGE_CLIENT_REF CLOUDFLARE_API_KEY CLOUDFLARE_ZONE_ID BLOCKED_EMAIL_DOMAINS
OTEL_PROVIDER LANGFUSE_PUBLIC_KEY LANGFUSE_SECRET_KEY LANGFUSE_BASE_URL]
end
def captain_config_options
%w[
CAPTAIN_OPEN_AI_API_KEY
CAPTAIN_OPEN_AI_MODEL
CAPTAIN_OPEN_AI_ENDPOINT
CAPTAIN_EMBEDDING_MODEL
CAPTAIN_FIRECRAWL_API_KEY
]
end
def saml_config_options
%w[ENABLE_SAML_SSO_LOGIN]
end
end

View File

@@ -0,0 +1,47 @@
class Enterprise::Webhooks::FirecrawlController < ActionController::API
before_action :validate_token
def process_payload
Captain::Tools::FirecrawlParserJob.perform_later(assistant_id: assistant.id, payload: payload) if crawl_page_event?
head :ok
end
private
include Captain::FirecrawlHelper
def payload
permitted_params[:data]&.first&.to_h
end
def validate_token
render json: { error: 'Invalid access_token' }, status: :unauthorized if assistant_token != permitted_params[:token]
end
def assistant
@assistant ||= Captain::Assistant.find(permitted_params[:assistant_id])
end
def assistant_token
generate_firecrawl_token(assistant.id, assistant.account_id)
end
def crawl_page_event?
permitted_params[:type] == 'crawl.page'
end
def permitted_params
params.permit(
:type,
:assistant_id,
:token,
:success,
:id,
:metadata,
:format,
:firecrawl,
data: [:markdown, { metadata: {} }]
)
end
end

View File

@@ -0,0 +1,21 @@
class Enterprise::Webhooks::StripeController < ActionController::API
def process_payload
# Get the event payload and signature
payload = request.body.read
sig_header = request.headers['Stripe-Signature']
# Attempt to verify the signature. If successful, we'll handle the event
begin
event = Stripe::Webhook.construct_event(payload, sig_header, ENV.fetch('STRIPE_WEBHOOK_SECRET', nil))
::Enterprise::Billing::HandleStripeEventService.new.perform(event: event)
# If we fail to verify the signature, then something was wrong with the request
rescue JSON::ParserError, Stripe::SignatureVerificationError
# Invalid payload
head :bad_request
return
end
# We've successfully processed the event without blowing up
head :ok
end
end

View File

@@ -0,0 +1,14 @@
module Enterprise::WidgetsController
private
def ensure_location_is_supported
countries = @web_widget.inbox.account.custom_attributes['allowed_countries']
return if countries.blank?
geocoder_result = IpLookupService.new.perform(request.remote_ip)
return unless geocoder_result
country_enabled = countries.include?(geocoder_result.country_code)
render json: { error: 'Location is not supported' }, status: :unauthorized unless country_enabled
end
end

View File

@@ -0,0 +1,2 @@
class SuperAdmin::EnterpriseBaseController < SuperAdmin::ApplicationController
end

View File

@@ -0,0 +1,44 @@
class SuperAdmin::ResponseDocumentsController < SuperAdmin::EnterpriseBaseController
# Overwrite any of the RESTful controller actions to implement custom behavior
# For example, you may want to send an email after a foo is updated.
#
# def update
# super
# send_foo_updated_email(requested_resource)
# end
# Override this method to specify custom lookup behavior.
# This will be used to set the resource for the `show`, `edit`, and `update`
# actions.
#
# def find_resource(param)
# Foo.find_by!(slug: param)
# end
# The result of this lookup will be available as `requested_resource`
# Override this if you have certain roles that require a subset
# this will be used to set the records shown on the `index` action.
#
# def scoped_resource
# if current_user.super_admin?
# resource_class
# else
# resource_class.with_less_stuff
# end
# end
# Override `resource_params` if you want to transform the submitted
# data before it's persisted. For example, the following would turn all
# empty values into nil values. It uses other APIs such as `resource_class`
# and `dashboard`:
#
# def resource_params
# params.require(resource_class.model_name.param_key).
# permit(dashboard.permitted_attributes(action_name)).
# transform_values { |value| value == "" ? nil : value }
# end
# See https://administrate-demo.herokuapp.com/customizing_controller_actions
# for more information
end

View File

@@ -0,0 +1,44 @@
class SuperAdmin::ResponsesController < SuperAdmin::EnterpriseBaseController
# Overwrite any of the RESTful controller actions to implement custom behavior
# For example, you may want to send an email after a foo is updated.
#
# def update
# super
# send_foo_updated_email(requested_resource)
# end
# Override this method to specify custom lookup behavior.
# This will be used to set the resource for the `show`, `edit`, and `update`
# actions.
#
# def find_resource(param)
# Foo.find_by!(slug: param)
# end
# The result of this lookup will be available as `requested_resource`
# Override this if you have certain roles that require a subset
# this will be used to set the records shown on the `index` action.
#
# def scoped_resource
# if current_user.super_admin?
# resource_class
# else
# resource_class.with_less_stuff
# end
# end
# Override `resource_params` if you want to transform the submitted
# data before it's persisted. For example, the following would turn all
# empty values into nil values. It uses other APIs such as `resource_class`
# and `dashboard`:
#
# def resource_params
# params.require(resource_class.model_name.param_key).
# permit(dashboard.permitted_attributes(action_name)).
# transform_values { |value| value == "" ? nil : value }
# end
# See https://administrate-demo.herokuapp.com/customizing_controller_actions
# for more information
end

View File

@@ -0,0 +1,188 @@
class Twilio::VoiceController < ApplicationController
CONFERENCE_EVENT_PATTERNS = {
/conference-start/i => 'start',
/participant-join/i => 'join',
/participant-leave/i => 'leave',
/conference-end/i => 'end'
}.freeze
before_action :set_inbox!
def status
Voice::StatusUpdateService.new(
account: current_account,
call_sid: twilio_call_sid,
call_status: params[:CallStatus],
payload: params.to_unsafe_h
).perform
head :no_content
end
def call_twiml
account = current_account
Rails.logger.info(
"TWILIO_VOICE_TWIML account=#{account.id} call_sid=#{twilio_call_sid} from=#{twilio_from} direction=#{twilio_direction}"
)
conversation = resolve_conversation
conference_sid = ensure_conference_sid!(conversation)
render xml: conference_twiml(conference_sid, agent_leg?(twilio_from))
end
def conference_status
event = mapped_conference_event
return head :no_content unless event
conversation = find_conversation_for_conference!(
friendly_name: params[:FriendlyName],
call_sid: twilio_call_sid
)
Voice::Conference::Manager.new(
conversation: conversation,
event: event,
call_sid: twilio_call_sid,
participant_label: participant_label
).process
head :no_content
end
private
def twilio_call_sid
params[:CallSid]
end
def twilio_from
params[:From].to_s
end
def twilio_to
params[:To]
end
def twilio_direction
@twilio_direction ||= (params['Direction'] || params['CallDirection']).to_s
end
def mapped_conference_event
event = params[:StatusCallbackEvent].to_s
CONFERENCE_EVENT_PATTERNS.each do |pattern, mapped|
return mapped if event.match?(pattern)
end
nil
end
def agent_leg?(from_number)
from_number.start_with?('client:')
end
def resolve_conversation
return find_conversation_for_agent if agent_leg?(twilio_from)
case twilio_direction
when 'inbound'
Voice::InboundCallBuilder.perform!(
account: current_account,
inbox: inbox,
from_number: twilio_from,
call_sid: twilio_call_sid
)
when 'outbound-api', 'outbound-dial'
sync_outbound_leg(
call_sid: twilio_call_sid,
from_number: twilio_from,
direction: twilio_direction
)
else
raise ArgumentError, "Unsupported Twilio direction: #{twilio_direction}"
end
end
def find_conversation_for_agent
if params[:conversation_id].present?
current_account.conversations.find_by!(display_id: params[:conversation_id])
else
current_account.conversations.find_by!(identifier: twilio_call_sid)
end
end
def sync_outbound_leg(call_sid:, from_number:, direction:)
parent_sid = params['ParentCallSid'].presence
lookup_sid = direction == 'outbound-dial' ? parent_sid || call_sid : call_sid
conversation = current_account.conversations.find_by!(identifier: lookup_sid)
Voice::CallSessionSyncService.new(
conversation: conversation,
call_sid: call_sid,
message_call_sid: conversation.identifier,
leg: {
from_number: from_number,
to_number: twilio_to,
direction: 'outbound'
}
).perform
end
def ensure_conference_sid!(conversation)
attrs = conversation.additional_attributes || {}
attrs['conference_sid'] ||= Voice::Conference::Name.for(conversation)
conversation.update!(additional_attributes: attrs)
attrs['conference_sid']
end
def conference_twiml(conference_sid, agent_leg)
Twilio::TwiML::VoiceResponse.new.tap do |response|
response.dial do |dial|
dial.conference(
conference_sid,
start_conference_on_enter: agent_leg,
end_conference_on_exit: false,
status_callback: conference_status_callback_url,
status_callback_event: 'start end join leave',
status_callback_method: 'POST',
participant_label: agent_leg ? 'agent' : 'contact'
)
end
end.to_s
end
def conference_status_callback_url
phone_digits = inbox_channel.phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_conference_status_url(phone: phone_digits)
end
def find_conversation_for_conference!(friendly_name:, call_sid:)
name = friendly_name.to_s
scope = current_account.conversations
if name.present?
conversation = scope.where("additional_attributes->>'conference_sid' = ?", name).first
return conversation if conversation
end
scope.find_by!(identifier: call_sid)
end
def set_inbox!
digits = params[:phone].to_s.gsub(/\D/, '')
e164 = "+#{digits}"
channel = Channel::Voice.find_by!(phone_number: e164)
@inbox = channel.inbox
end
def current_account
@current_account ||= inbox_account
end
def participant_label
params[:ParticipantLabel].to_s
end
attr_reader :inbox
delegate :account, :channel, to: :inbox, prefix: true
end

View File

@@ -0,0 +1,7 @@
module Enterprise::AsyncDispatcher
def listeners
super + [
CaptainListener.instance
]
end
end

View File

@@ -0,0 +1,9 @@
class SlaPolicyDrop < BaseDrop
def name
@obj.try(:name)
end
def description
@obj.try(:description)
end
end

View File

@@ -0,0 +1,7 @@
require 'administrate/field/base'
class AccountFeaturesField < Administrate::Field::Base
def to_s
data
end
end

View File

@@ -0,0 +1,10 @@
require 'administrate/field/base'
class AccountLimitsField < Administrate::Field::Base
def to_s
defaults = { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil, emails: nil }
overrides = (data.presence || {}).to_h.symbolize_keys.compact
defaults.merge(overrides).to_json
end
end

View File

@@ -0,0 +1,31 @@
require 'administrate/field/base'
class ManuallyManagedFeaturesField < Administrate::Field::Base
def data
Internal::Accounts::InternalAttributesService.new(resource).manually_managed_features
end
def to_s
data.is_a?(Array) ? data.join(', ') : '[]'
end
def all_features
# Business and Enterprise plan features only
Enterprise::Billing::HandleStripeEventService::BUSINESS_PLAN_FEATURES +
Enterprise::Billing::HandleStripeEventService::ENTERPRISE_PLAN_FEATURES
end
def selected_features
# If we have direct array data, use it (for rendering after form submission)
return data if data.is_a?(Array)
# Otherwise, use the service to retrieve the data from internal_attributes
if resource.respond_to?(:internal_attributes)
service = Internal::Accounts::InternalAttributesService.new(resource)
return service.manually_managed_features
end
# Fallback to empty array if no data available
[]
end
end

View File

@@ -0,0 +1,5 @@
module Enterprise::ConversationFinder
def conversations_base_query
current_account.feature_enabled?('sla') ? super.includes(:applied_sla, :sla_events) : super
end
end

View File

@@ -0,0 +1,47 @@
module Captain::ChatGenerationRecorder
extend ActiveSupport::Concern
include Integrations::LlmInstrumentationConstants
private
def record_llm_generation(chat, message)
return unless valid_llm_message?(message)
# Create a generation span with model and token info for Langfuse cost calculation.
# Note: span duration will be near-zero since we create and end it immediately, but token counts are what Langfuse uses for cost calculation.
tracer.in_span("llm.captain.#{feature_name}.generation") do |span|
set_generation_span_attributes(span, chat, message)
end
rescue StandardError => e
Rails.logger.warn "Failed to record LLM generation: #{e.message}"
end
# Skip non-LLM messages (e.g., tool results that RubyLLM processes internally).
# Check for assistant role rather than token presence - some providers/streaming modes
# may not return token counts, but we still want to capture the generation for evals.
def valid_llm_message?(message)
message.respond_to?(:role) && message.role.to_s == 'assistant'
end
def set_generation_span_attributes(span, chat, message)
generation_attributes(chat, message).each do |key, value|
span.set_attribute(key, value) if value
end
end
def generation_attributes(chat, message)
{
ATTR_GEN_AI_PROVIDER => determine_provider(model),
ATTR_GEN_AI_REQUEST_MODEL => model,
ATTR_GEN_AI_REQUEST_TEMPERATURE => temperature,
ATTR_GEN_AI_USAGE_INPUT_TOKENS => message.input_tokens,
ATTR_GEN_AI_USAGE_OUTPUT_TOKENS => message.respond_to?(:output_tokens) ? message.output_tokens : nil,
ATTR_LANGFUSE_OBSERVATION_INPUT => format_input_messages(chat),
ATTR_LANGFUSE_OBSERVATION_OUTPUT => message.respond_to?(:content) ? message.content.to_s : nil
}
end
def format_input_messages(chat)
chat.messages[0...-1].map { |m| { role: m.role.to_s, content: m.content.to_s } }.to_json
end
end

View File

@@ -0,0 +1,136 @@
module Captain::ChatHelper
include Integrations::LlmInstrumentation
include Captain::ChatResponseHelper
include Captain::ChatGenerationRecorder
def request_chat_completion
log_chat_completion_request
chat = build_chat
add_messages_to_chat(chat)
with_agent_session do
last_content = conversation_messages.last[:content]
text, attachments = Captain::OpenAiMessageBuilderService.extract_text_and_attachments(last_content)
response = attachments.any? ? chat.ask(text, with: attachments) : chat.ask(text)
build_response(response)
end
rescue StandardError => e
Rails.logger.error "#{self.class.name} Assistant: #{@assistant.id}, Error in chat completion: #{e}"
raise e
end
private
def build_chat
llm_chat = chat(model: @model, temperature: temperature)
llm_chat = llm_chat.with_params(response_format: { type: 'json_object' })
llm_chat = setup_tools(llm_chat)
llm_chat = setup_system_instructions(llm_chat)
setup_event_handlers(llm_chat)
end
def setup_tools(llm_chat)
@tools&.each do |tool|
llm_chat = llm_chat.with_tool(tool)
end
llm_chat
end
def setup_system_instructions(chat)
system_messages = @messages.select { |m| m[:role] == 'system' || m[:role] == :system }
combined_instructions = system_messages.pluck(:content).join("\n\n")
chat.with_instructions(combined_instructions)
end
def setup_event_handlers(chat)
# NOTE: We only use on_end_message to record the generation with token counts.
# RubyLLM callbacks fire after chunks arrive, not around the API call, so
# span timing won't reflect actual API latency. But Langfuse calculates costs
# from model + token counts, so this is sufficient for cost tracking.
chat.on_end_message { |message| record_llm_generation(chat, message) }
chat.on_tool_call { |tool_call| handle_tool_call(tool_call) }
chat.on_tool_result { |result| handle_tool_result(result) }
chat
end
def handle_tool_call(tool_call)
persist_thinking_message(tool_call)
start_tool_span(tool_call)
(@pending_tool_calls ||= []).push(tool_call)
end
def handle_tool_result(result)
end_tool_span(result)
persist_tool_completion
end
def add_messages_to_chat(chat)
conversation_messages[0...-1].each do |msg|
text, attachments = Captain::OpenAiMessageBuilderService.extract_text_and_attachments(msg[:content])
content = attachments.any? ? RubyLLM::Content.new(text, attachments) : text
chat.add_message(role: msg[:role].to_sym, content: content)
end
end
def instrumentation_params(chat = nil)
{
span_name: "llm.captain.#{feature_name}",
account_id: resolved_account_id,
conversation_id: @conversation_id,
feature_name: feature_name,
model: @model,
messages: chat ? chat.messages.map { |m| { role: m.role.to_s, content: m.content.to_s } } : @messages,
temperature: temperature,
metadata: {
assistant_id: @assistant&.id,
channel_type: resolved_channel_type
}.compact
}
end
def conversation_messages
@messages.reject { |m| m[:role] == 'system' || m[:role] == :system }
end
def temperature
@assistant&.config&.[]('temperature').to_f || 1
end
def resolved_account_id
@account&.id || @assistant&.account_id
end
def resolved_channel_type
Conversation.find_by(account_id: resolved_account_id, display_id: @conversation_id)&.inbox&.channel_type if @conversation_id
end
# Ensures all LLM calls and tool executions within an agentic loop
# are grouped under a single trace/session in Langfuse.
#
# Without this guard, each recursive call to request_chat_completion
# (triggered by tool calls) would create a separate trace instead of
# nesting within the existing session span.
def with_agent_session(&)
already_active = @agent_session_active
return yield if already_active
@agent_session_active = true
instrument_agent_session(instrumentation_params, &)
ensure
@agent_session_active = false unless already_active
end
# Must be implemented by including class to identify the feature for instrumentation.
# Used for Langfuse tagging and span naming.
def feature_name
raise NotImplementedError, "#{self.class.name} must implement #feature_name"
end
def log_chat_completion_request
Rails.logger.info("#{self.class.name} Assistant: #{@assistant.id}, Requesting chat completion " \
"for messages #{@messages} with #{@tools&.length || 0} tools")
end
end

View File

@@ -0,0 +1,75 @@
module Captain::ChatResponseHelper
include Integrations::LlmInstrumentationConstants
private
def build_response(response)
Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
parsed = parse_json_response(response.content)
apply_credit_usage_metadata(parsed)
persist_message(parsed, 'assistant')
parsed
end
def parse_json_response(content)
content = content.gsub('```json', '').gsub('```', '')
content = content.strip
JSON.parse(content)
rescue JSON::ParserError => e
Rails.logger.error "#{self.class.name} Assistant: #{@assistant.id}, Error parsing JSON response: #{e.message}"
{ 'content' => content }
end
def apply_credit_usage_metadata(parsed_response)
return unless captain_v1_assistant?
OpenTelemetry::Trace.current_span.set_attribute(
format(ATTR_LANGFUSE_METADATA, 'credit_used'),
credit_used_for_response?(parsed_response).to_s
)
rescue StandardError => e
Rails.logger.warn "#{self.class.name} Assistant: #{@assistant.id}, Failed to set credit usage metadata: #{e.message}"
end
def credit_used_for_response?(parsed_response)
response = parsed_response['response']
response.present? && response != 'conversation_handoff'
end
def captain_v1_assistant?
feature_name == 'assistant' && !@assistant.account.feature_enabled?('captain_integration_v2')
end
def persist_thinking_message(tool_call)
return if @copilot_thread.blank?
tool_name = tool_call.name.to_s
persist_message(
{
'content' => "Using #{tool_name}",
'function_name' => tool_name
},
'assistant_thinking'
)
end
def persist_tool_completion
return if @copilot_thread.blank?
tool_call = @pending_tool_calls&.pop
return unless tool_call
tool_name = tool_call.name.to_s
persist_message(
{
'content' => "Completed #{tool_name}",
'function_name' => tool_name
},
'assistant_thinking'
)
end
end

View File

@@ -0,0 +1,9 @@
module Captain::FirecrawlHelper
def generate_firecrawl_token(assistant_id, account_id)
api_key = InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value
return nil unless api_key
token_base = "#{api_key[-4..]}#{assistant_id}#{account_id}"
Digest::SHA256.hexdigest(token_base)
end
end

View File

@@ -0,0 +1,12 @@
module SamlAuthenticationHelper
def saml_user_attempting_password_auth?(email, sso_auth_token: nil)
return false if email.blank?
user = User.from_email(email)
return false unless user&.provider == 'saml'
return false if sso_auth_token.present? && user.valid_sso_auth_token?(sso_auth_token)
true
end
end

View File

@@ -0,0 +1,147 @@
class Captain::Conversation::ResponseBuilderJob < ApplicationJob
MAX_MESSAGE_LENGTH = 10_000
retry_on ActiveStorage::FileNotFoundError, attempts: 3, wait: 2.seconds
retry_on Faraday::BadRequestError, attempts: 3, wait: 2.seconds
def perform(conversation, assistant)
@conversation = conversation
@inbox = conversation.inbox
@assistant = assistant
Current.executed_by = @assistant
if captain_v2_enabled?
generate_response_with_v2
else
generate_and_process_response
end
rescue StandardError => e
raise e if e.is_a?(ActiveStorage::FileNotFoundError) || e.is_a?(Faraday::BadRequestError)
handle_error(e)
ensure
Current.executed_by = nil
end
private
delegate :account, :inbox, to: :@conversation
def generate_and_process_response
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant, conversation_id: @conversation.display_id).generate_response(
message_history: collect_previous_messages
)
process_response
end
def generate_response_with_v2
@response = Captain::Assistant::AgentRunnerService.new(assistant: @assistant, conversation: @conversation).generate_response(
message_history: collect_previous_messages
)
process_response
end
def process_response
ActiveRecord::Base.transaction do
if handoff_requested?
process_action('handoff')
else
create_messages
Rails.logger.info("[CAPTAIN][ResponseBuilderJob] Incrementing response usage for #{account.id}")
account.increment_response_usage
end
end
end
def collect_previous_messages
@conversation
.messages
.where(message_type: [:incoming, :outgoing])
.where(private: false)
.map do |message|
message_hash = {
content: prepare_multimodal_message_content(message),
role: determine_role(message)
}
# Include agent_name if present in additional_attributes
message_hash[:agent_name] = message.additional_attributes['agent_name'] if message.additional_attributes&.dig('agent_name').present?
message_hash
end
end
def determine_role(message)
message.message_type == 'incoming' ? 'user' : 'assistant'
end
def prepare_multimodal_message_content(message)
Captain::OpenAiMessageBuilderService.new(message: message).generate_content
end
def handoff_requested?
@response['response'] == 'conversation_handoff'
end
def process_action(action)
case action
when 'handoff'
I18n.with_locale(@assistant.account.locale) do
create_handoff_message
@conversation.bot_handoff!
send_out_of_office_message_if_applicable
end
end
end
def send_out_of_office_message_if_applicable
# Campaign conversations should never receive OOO templates — the campaign itself
# serves as the initial outreach, and OOO would be confusing in that context.
return if @conversation.campaign.present?
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(@conversation)
end
def create_handoff_message
create_outgoing_message(
@assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff')
)
end
def create_messages
validate_message_content!(@response['response'])
create_outgoing_message(@response['response'], agent_name: @response['agent_name'])
end
def validate_message_content!(content)
raise ArgumentError, 'Message content cannot be blank' if content.blank?
end
def create_outgoing_message(message_content, agent_name: nil)
additional_attrs = {}
additional_attrs[:agent_name] = agent_name if agent_name.present?
@conversation.messages.create!(
message_type: :outgoing,
account_id: account.id,
inbox_id: inbox.id,
sender: @assistant,
content: message_content,
additional_attributes: additional_attrs
)
end
def handle_error(error)
log_error(error)
process_action('handoff')
true
end
def log_error(error)
ChatwootExceptionTracker.new(error, account: account).capture_exception
end
def captain_v2_enabled?
account.feature_enabled?('captain_integration_v2')
end
end

View File

@@ -0,0 +1,28 @@
class Captain::Copilot::ResponseJob < ApplicationJob
queue_as :default
def perform(assistant:, conversation_id:, user_id:, copilot_thread_id:, message:)
Rails.logger.info("#{self.class.name} Copilot response job for assistant_id=#{assistant.id} user_id=#{user_id}")
generate_chat_response(
assistant: assistant,
conversation_id: conversation_id,
user_id: user_id,
copilot_thread_id: copilot_thread_id,
message: message
)
end
private
def generate_chat_response(assistant:, conversation_id:, user_id:, copilot_thread_id:, message:)
service = Captain::Copilot::ChatService.new(
assistant,
user_id: user_id,
copilot_thread_id: copilot_thread_id,
conversation_id: conversation_id
)
# When using copilot_thread, message is already in previous_history
# Pass nil to avoid duplicate
service.generate_response(copilot_thread_id.present? ? nil : message)
end
end

View File

@@ -0,0 +1,61 @@
class Captain::Documents::CrawlJob < ApplicationJob
queue_as :low
def perform(document)
if document.pdf_document?
perform_pdf_processing(document)
elsif InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value.present?
perform_firecrawl_crawl(document)
else
perform_simple_crawl(document)
end
end
private
include Captain::FirecrawlHelper
def perform_pdf_processing(document)
Captain::Llm::PdfProcessingService.new(document).process
document.update!(status: :available)
rescue StandardError => e
Rails.logger.error I18n.t('captain.documents.pdf_processing_failed', document_id: document.id, error: e.message)
raise # Re-raise to let job framework handle retry logic
end
def perform_simple_crawl(document)
page_links = Captain::Tools::SimplePageCrawlService.new(document.external_link).page_links
page_links.each do |page_link|
Captain::Tools::SimplePageCrawlParserJob.perform_later(
assistant_id: document.assistant_id,
page_link: page_link
)
end
Captain::Tools::SimplePageCrawlParserJob.perform_later(
assistant_id: document.assistant_id,
page_link: document.external_link
)
end
def perform_firecrawl_crawl(document)
captain_usage_limits = document.account.usage_limits[:captain] || {}
document_limit = captain_usage_limits[:documents] || {}
crawl_limit = [document_limit[:current_available] || 10, 500].min
Captain::Tools::FirecrawlService
.new
.perform(
document.external_link,
firecrawl_webhook_url(document),
crawl_limit
)
end
def firecrawl_webhook_url(document)
webhook_url = Rails.application.routes.url_helpers.enterprise_webhooks_firecrawl_url
"#{webhook_url}?assistant_id=#{document.assistant_id}&token=#{generate_firecrawl_token(document.assistant_id, document.account_id)}"
end
end

View File

@@ -0,0 +1,78 @@
class Captain::Documents::ResponseBuilderJob < ApplicationJob
queue_as :low
def perform(document, options = {})
reset_previous_responses(document)
faqs = generate_faqs(document, options)
create_responses_from_faqs(faqs, document)
end
private
def generate_faqs(document, options)
if should_use_pagination?(document)
generate_paginated_faqs(document, options)
else
generate_standard_faqs(document)
end
end
def generate_paginated_faqs(document, options)
service = build_paginated_service(document, options)
faqs = service.generate
store_paginated_metadata(document, service)
faqs
end
def generate_standard_faqs(document)
Captain::Llm::FaqGeneratorService.new(document.content, document.account.locale_english_name, account_id: document.account_id).generate
end
def build_paginated_service(document, options)
Captain::Llm::PaginatedFaqGeneratorService.new(
document,
pages_per_chunk: options[:pages_per_chunk],
max_pages: options[:max_pages],
language: document.account.locale_english_name
)
end
def store_paginated_metadata(document, service)
document.update!(
metadata: (document.metadata || {}).merge(
'faq_generation' => {
'method' => 'paginated',
'pages_processed' => service.total_pages_processed,
'iterations' => service.iterations_completed,
'timestamp' => Time.current.iso8601
}
)
)
end
def create_responses_from_faqs(faqs, document)
faqs.each { |faq| create_response(faq, document) }
end
def should_use_pagination?(document)
# Auto-detect when to use pagination
# For now, use pagination for PDFs with OpenAI file ID
document.pdf_document? && document.openai_file_id.present?
end
def reset_previous_responses(response_document)
response_document.responses.destroy_all
end
def create_response(faq, document)
document.responses.create!(
question: faq['question'],
answer: faq['answer'],
assistant: document.assistant,
documentable: document
)
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error I18n.t('captain.documents.response_creation_error', error: e.message)
end
end

View File

@@ -0,0 +1,32 @@
class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
queue_as :low
def perform(inbox)
Current.executed_by = inbox.captain_assistant
resolvable_conversations = inbox.conversations.pending.where('last_activity_at < ? ', Time.now.utc - 1.hour).limit(Limits::BULK_ACTIONS_LIMIT)
resolvable_conversations.each do |conversation|
create_outgoing_message(conversation, inbox)
conversation.resolved!
end
ensure
Current.reset
end
private
def create_outgoing_message(conversation, inbox)
I18n.with_locale(inbox.account.locale) do
resolution_message = inbox.captain_assistant.config['resolution_message']
conversation.messages.create!(
{
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
content: resolution_message.presence || I18n.t('conversations.activity.auto_resolution_message'),
sender: inbox.captain_assistant
}
)
end
end
end

View File

@@ -0,0 +1,9 @@
class Captain::Llm::UpdateEmbeddingJob < ApplicationJob
queue_as :low
def perform(record, content)
account_id = record.account_id
embedding = Captain::Llm::EmbeddingService.new(account_id: account_id).get_embedding(content)
record.update!(embedding: embedding)
end
end

View File

@@ -0,0 +1,28 @@
class Captain::Tools::FirecrawlParserJob < ApplicationJob
queue_as :low
def perform(assistant_id:, payload:)
assistant = Captain::Assistant.find(assistant_id)
metadata = payload[:metadata]
canonical_url = normalize_link(metadata['url'])
document = assistant.documents.find_or_initialize_by(
external_link: canonical_url
)
document.update!(
external_link: canonical_url,
content: payload[:markdown],
name: metadata['title'],
status: :available
)
rescue StandardError => e
raise "Failed to parse FireCrawl data: #{e.message}"
end
private
def normalize_link(raw_url)
raw_url.to_s.delete_suffix('/')
end
end

View File

@@ -0,0 +1,39 @@
class Captain::Tools::SimplePageCrawlParserJob < ApplicationJob
queue_as :low
def perform(assistant_id:, page_link:)
assistant = Captain::Assistant.find(assistant_id)
account = assistant.account
if limit_exceeded?(account)
Rails.logger.info("Document limit exceeded for #{assistant_id}")
return
end
crawler = Captain::Tools::SimplePageCrawlService.new(page_link)
page_title = crawler.page_title || ''
content = crawler.body_text_content || ''
normalized_link = normalize_link(page_link)
document = assistant.documents.find_or_initialize_by(external_link: normalized_link)
document.update!(
external_link: normalized_link,
name: page_title[0..254], content: content[0..14_999], status: :available
)
rescue StandardError => e
raise "Failed to parse data: #{page_link} #{e.message}"
end
private
def normalize_link(raw_link)
raw_link.to_s.delete_suffix('/')
end
def limit_exceeded?(account)
limits = account.usage_limits[:captain][:documents]
limits[:current_available].negative? || limits[:current_available].zero?
end
end

View File

@@ -0,0 +1,21 @@
module Enterprise::Account::ConversationsResolutionSchedulerJob
def perform
super
resolve_captain_conversations
end
private
def resolve_captain_conversations
CaptainInbox.all.find_each(batch_size: 100) do |captain_inbox|
inbox = captain_inbox.inbox
next if inbox.email?
Captain::InboxPendingConversationsResolutionJob.perform_later(
inbox
)
end
end
end

View File

@@ -0,0 +1,22 @@
class Enterprise::CloudflareVerificationJob < ApplicationJob
queue_as :default
def perform(portal_id)
portal = Portal.find(portal_id)
return unless portal && portal.custom_domain.present?
result = check_hostname_status(portal)
create_hostname(portal) if result[:errors].present?
end
private
def create_hostname(portal)
Cloudflare::CreateCustomHostnameService.new(portal: portal).perform
end
def check_hostname_status(portal)
Cloudflare::CheckCustomHostnameService.new(portal: portal).perform
end
end

View File

@@ -0,0 +1,7 @@
class Enterprise::CreateStripeCustomerJob < ApplicationJob
queue_as :default
def perform(account)
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
end
end

View File

@@ -0,0 +1,26 @@
module Enterprise::DeleteObjectJob
private
def heavy_associations
super.merge(
SlaPolicy => %i[applied_slas]
).freeze
end
def process_post_deletion_tasks(object, user, ip)
create_audit_entry(object, user, ip)
end
def create_audit_entry(object, user, ip)
return unless %w[Inbox Conversation SlaPolicy].include?(object.class.to_s) && user.present?
Enterprise::AuditLog.create(
auditable: object,
audited_changes: object.attributes,
action: 'destroy',
user: user,
associated: object.account,
remote_address: ip
)
end
end

View File

@@ -0,0 +1,30 @@
module Enterprise::Internal::CheckNewVersionsJob
def perform
super
update_plan_info
reconcile_premium_config_and_features
end
private
def update_plan_info
return if @instance_info.blank?
update_installation_config(key: 'INSTALLATION_PRICING_PLAN', value: @instance_info['plan'])
update_installation_config(key: 'INSTALLATION_PRICING_PLAN_QUANTITY', value: @instance_info['plan_quantity'])
update_installation_config(key: 'CHATWOOT_SUPPORT_WEBSITE_TOKEN', value: @instance_info['chatwoot_support_website_token'])
update_installation_config(key: 'CHATWOOT_SUPPORT_IDENTIFIER_HASH', value: @instance_info['chatwoot_support_identifier_hash'])
update_installation_config(key: 'CHATWOOT_SUPPORT_SCRIPT_URL', value: @instance_info['chatwoot_support_script_url'])
end
def update_installation_config(key:, value:)
config = InstallationConfig.find_or_initialize_by(name: key)
config.value = value
config.locked = true
config.save!
end
def reconcile_premium_config_and_features
Internal::ReconcilePlanConfigService.new.perform
end
end

View File

@@ -0,0 +1,11 @@
module Enterprise::TriggerScheduledItemsJob
def perform
super
## Triggers Enterprise specific jobs
####################################
# Triggers Account Sla jobs
Sla::TriggerSlasForAccountsJob.perform_later
end
end

View File

@@ -0,0 +1,9 @@
class Internal::AccountAnalysisJob < ApplicationJob
queue_as :low
def perform(account)
return unless ChatwootApp.chatwoot_cloud?
Internal::AccountAnalysis::ThreatAnalyserService.new(account).perform
end
end

View File

@@ -0,0 +1,21 @@
class Messages::AudioTranscriptionJob < ApplicationJob
queue_as :low
discard_on Faraday::BadRequestError do |job, error|
log_context = {
attachment_id: job.arguments.first,
job_id: job.job_id,
status_code: error.response&.dig(:status)
}
Rails.logger.warn("Discarding audio transcription job due to bad request: #{log_context}")
end
retry_on ActiveStorage::FileNotFoundError, wait: 2.seconds, attempts: 3
def perform(attachment_id)
attachment = Attachment.find_by(id: attachment_id)
return if attachment.blank?
Messages::AudioTranscriptionService.new(attachment).perform
end
end

View File

@@ -0,0 +1,54 @@
class Migration::CompanyAccountBatchJob < ApplicationJob
queue_as :low
def perform(account)
account.contacts
.where.not(email: nil)
.find_in_batches(batch_size: 1000) do |contact_batch|
process_contact_batch(contact_batch, account)
end
end
private
def process_contact_batch(contacts, account)
contacts.each do |contact|
next unless should_process?(contact)
company = find_or_create_company(contact, account)
# rubocop:disable Rails/SkipsModelValidations
contact.update_column(:company_id, company.id) if company
# rubocop:enable Rails/SkipsModelValidations
end
end
def should_process?(contact)
return false if contact.company_id.present?
return false if contact.email.blank?
Companies::BusinessEmailDetectorService.new(contact.email).perform
end
def find_or_create_company(contact, account)
domain = extract_domain(contact.email)
company_name = derive_company_name(contact, domain)
Company.find_or_create_by!(account: account, domain: domain) do |company|
company.name = company_name
end
rescue ActiveRecord::RecordNotUnique
# Race condition: Another job created it between our check and create
# just find the one that was created
Company.find_by(account: account, domain: domain)
end
def extract_domain(email)
email.split('@').last&.downcase
end
def derive_company_name(contact, domain)
contact.additional_attributes&.dig('company_name').presence ||
domain.split('.').first.tr('-_', ' ').titleize
end
end

View File

@@ -0,0 +1,17 @@
class Migration::CompanyBackfillJob < ApplicationJob
queue_as :low
def perform
Rails.logger.info 'Starting company backfill migration...'
account_count = 0
Account.find_in_batches(batch_size: 100) do |accounts|
accounts.each do |account|
Rails.logger.info "Enqueuing company backfill for account #{account.id}"
Migration::CompanyAccountBatchJob.perform_later(account)
account_count += 1
end
end
Rails.logger.info "Company backfill migration complete. Enqueued jobs for #{account_count} accounts."
end
end

View File

@@ -0,0 +1,7 @@
class Portal::ArticleIndexingJob < ApplicationJob
queue_as :low
def perform(article)
article.generate_and_save_article_seach_terms
end
end

View File

@@ -0,0 +1,33 @@
class Saml::UpdateAccountUsersProviderJob < ApplicationJob
queue_as :default
# Updates the authentication provider for users in an account
# This job is triggered when SAML settings are created or destroyed
def perform(account_id, provider)
account = Account.find(account_id)
account.users.find_each(batch_size: 1000) do |user|
next unless should_update_user_provider?(user, provider)
# rubocop:disable Rails/SkipsModelValidations
user.update_column(:provider, provider)
# rubocop:enable Rails/SkipsModelValidations
end
end
private
# Determines if a user's provider should be updated based on their multi-account status
# When resetting to 'email', only update users who don't have SAML enabled on other accounts
# This prevents breaking SAML authentication for users who belong to multiple accounts
def should_update_user_provider?(user, provider)
return !user_has_other_saml_accounts?(user) if provider == 'email'
true
end
# Checks if the user belongs to any other accounts that have SAML configured
# Used to preserve SAML authentication when one account disables SAML but others still use it
def user_has_other_saml_accounts?(user)
user.accounts.joins(:saml_settings).exists?
end
end

View File

@@ -0,0 +1,9 @@
class Sla::ProcessAccountAppliedSlasJob < ApplicationJob
queue_as :medium
def perform(account)
account.applied_slas.where(sla_status: %w[active active_with_misses]).each do |applied_sla|
Sla::ProcessAppliedSlaJob.perform_later(applied_sla)
end
end
end

View File

@@ -0,0 +1,7 @@
class Sla::ProcessAppliedSlaJob < ApplicationJob
queue_as :medium
def perform(applied_sla)
Sla::EvaluateAppliedSlaService.new(applied_sla: applied_sla).perform
end
end

View File

@@ -0,0 +1,10 @@
class Sla::TriggerSlasForAccountsJob < ApplicationJob
queue_as :scheduled_jobs
def perform
Account.joins(:sla_policies).distinct.find_each do |account|
Rails.logger.info "Enqueuing ProcessAccountAppliedSlasJob for account #{account.id}"
Sla::ProcessAccountAppliedSlasJob.perform_later(account)
end
end
end

View File

@@ -0,0 +1,13 @@
class CaptainListener < BaseListener
include ::Events::Types
def conversation_resolved(event)
conversation = extract_conversation_and_account(event)[0]
assistant = conversation.inbox.captain_assistant
return unless conversation.inbox.captain_active?
Captain::Llm::ContactNotesService.new(assistant, conversation).generate_and_update_notes if assistant.config['feature_memory'].present?
Captain::Llm::ConversationFaqService.new(assistant, conversation).generate_and_deduplicate if assistant.config['feature_faq'].present?
end
end

View File

@@ -0,0 +1,11 @@
module Enterprise::ActionCableListener
include Events::Types
def copilot_message_created(event)
copilot_message = event.data[:copilot_message]
copilot_thread = copilot_message.copilot_thread
account = copilot_thread.account
user = copilot_thread.user
broadcast(account, [user.pubsub_token], COPILOT_MESSAGE_CREATED, copilot_message.push_event_data)
end
end

View File

@@ -0,0 +1,38 @@
module Enterprise::AgentNotifications::ConversationNotificationsMailer
def sla_missed_first_response(conversation, agent, sla_policy)
return unless smtp_config_set_or_development?
@agent = agent
@conversation = conversation
@sla_policy = sla_policy
subject = "Conversation [ID - #{@conversation.display_id}] missed SLA for first response"
@action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id)
send_mail_with_liquid(to: @agent.email, subject: subject) and return
end
def sla_missed_next_response(conversation, agent, sla_policy)
return unless smtp_config_set_or_development?
@agent = agent
@conversation = conversation
@sla_policy = sla_policy
@action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id)
send_mail_with_liquid(to: @agent.email, subject: "Conversation [ID - #{@conversation.display_id}] missed SLA for next response") and return
end
def sla_missed_resolution(conversation, agent, sla_policy)
return unless smtp_config_set_or_development?
@agent = agent
@conversation = conversation
@sla_policy = sla_policy
@action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id)
send_mail_with_liquid(to: @agent.email, subject: "Conversation [ID - #{@conversation.display_id}] missed SLA for resolution time") and return
end
def liquid_droppables
super.merge({
sla_policy: @sla_policy
})
end
end

View File

@@ -0,0 +1,79 @@
# == Schema Information
#
# Table name: account_saml_settings
#
# id :bigint not null, primary key
# certificate :text
# role_mappings :json
# sso_url :string
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# idp_entity_id :string
# sp_entity_id :string
#
# Indexes
#
# index_account_saml_settings_on_account_id (account_id)
#
class AccountSamlSettings < ApplicationRecord
belongs_to :account
validates :account_id, presence: true
validates :sso_url, presence: true
validates :certificate, presence: true
validates :idp_entity_id, presence: true
validate :certificate_must_be_valid_x509
before_validation :set_sp_entity_id, if: :sp_entity_id_needs_generation?
after_create_commit :update_account_users_provider
after_destroy_commit :reset_account_users_provider
def saml_enabled?
sso_url.present? && certificate.present?
end
def certificate_fingerprint
return nil if certificate.blank?
begin
cert = OpenSSL::X509::Certificate.new(certificate)
OpenSSL::Digest::SHA1.new(cert.to_der).hexdigest
.upcase.gsub(/(.{2})(?=.)/, '\1:')
rescue OpenSSL::X509::CertificateError
nil
end
end
private
def set_sp_entity_id
base_url = GlobalConfigService.load('FRONTEND_URL', 'http://localhost:3000')
self.sp_entity_id = "#{base_url}/saml/sp/#{account_id}"
end
def sp_entity_id_needs_generation?
sp_entity_id.blank?
end
def installation_name
GlobalConfigService.load('INSTALLATION_NAME', 'Chatwoot')
end
def update_account_users_provider
Saml::UpdateAccountUsersProviderJob.perform_later(account_id, 'saml')
end
def reset_account_users_provider
Saml::UpdateAccountUsersProviderJob.perform_later(account_id, 'email')
end
def certificate_must_be_valid_x509
return if certificate.blank?
OpenSSL::X509::Certificate.new(certificate)
rescue OpenSSL::X509::CertificateError
errors.add(:certificate, I18n.t('errors.account_saml_settings.invalid_certificate'))
end
end

View File

@@ -0,0 +1,27 @@
# == Schema Information
#
# Table name: agent_capacity_policies
#
# id :bigint not null, primary key
# description :text
# exclusion_rules :jsonb not null
# name :string(255) not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
# index_agent_capacity_policies_on_account_id (account_id)
#
class AgentCapacityPolicy < ApplicationRecord
MAX_NAME_LENGTH = 255
belongs_to :account
has_many :inbox_capacity_limits, dependent: :destroy
has_many :inboxes, through: :inbox_capacity_limits
has_many :account_users, dependent: :nullify
validates :name, presence: true, length: { maximum: MAX_NAME_LENGTH }
validates :account, presence: true
end

View File

@@ -0,0 +1,77 @@
# == Schema Information
#
# Table name: applied_slas
#
# id :bigint not null, primary key
# sla_status :integer default("active")
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# conversation_id :bigint not null
# sla_policy_id :bigint not null
#
# Indexes
#
# index_applied_slas_on_account_id (account_id)
# index_applied_slas_on_account_sla_policy_conversation (account_id,sla_policy_id,conversation_id) UNIQUE
# index_applied_slas_on_conversation_id (conversation_id)
# index_applied_slas_on_sla_policy_id (sla_policy_id)
#
class AppliedSla < ApplicationRecord
belongs_to :account
belongs_to :sla_policy
belongs_to :conversation
has_many :sla_events, dependent: :destroy_async
validates :account_id, uniqueness: { scope: %i[sla_policy_id conversation_id] }
before_validation :ensure_account_id
enum sla_status: { active: 0, hit: 1, missed: 2, active_with_misses: 3 }
scope :filter_by_date_range, ->(range) { where(created_at: range) if range.present? }
scope :filter_by_inbox_id, ->(inbox_id) { joins(:conversation).where(conversations: { inbox_id: inbox_id }) if inbox_id.present? }
scope :filter_by_team_id, ->(team_id) { joins(:conversation).where(conversations: { team_id: team_id }) if team_id.present? }
scope :filter_by_sla_policy_id, ->(sla_policy_id) { where(sla_policy_id: sla_policy_id) if sla_policy_id.present? }
scope :filter_by_label_list, lambda { |label_list|
joins(:conversation).where('conversations.cached_label_list LIKE ?', "%#{label_list}%") if label_list.present?
}
scope :filter_by_assigned_agent_id, lambda { |assigned_agent_id|
joins(:conversation).where(conversations: { assignee_id: assigned_agent_id }) if assigned_agent_id.present?
}
scope :missed, -> { where(sla_status: %i[missed active_with_misses]) }
after_update_commit :push_conversation_event
def push_event_data
{
id: id,
sla_id: sla_policy_id,
sla_status: sla_status,
created_at: created_at.to_i,
updated_at: updated_at.to_i,
sla_description: sla_policy.description,
sla_name: sla_policy.name,
sla_first_response_time_threshold: sla_policy.first_response_time_threshold,
sla_next_response_time_threshold: sla_policy.next_response_time_threshold,
sla_only_during_business_hours: sla_policy.only_during_business_hours,
sla_resolution_time_threshold: sla_policy.resolution_time_threshold
}
end
private
def push_conversation_event
# right now we simply use `CONVERSATION_UPDATED` event to notify the frontend
# we can eventually start using `CONVERSATION_SLA_UPDATED` event as required later
# for now the updated event should suffice
return unless saved_change_to_sla_status?
conversation.dispatch_conversation_updated_event
end
def ensure_account_id
self.account_id ||= sla_policy&.account_id
end
end

View File

@@ -0,0 +1,31 @@
# == Schema Information
#
# Table name: article_embeddings
#
# id :bigint not null, primary key
# embedding :vector(1536)
# term :text not null
# created_at :datetime not null
# updated_at :datetime not null
# article_id :bigint not null
#
# Indexes
#
# index_article_embeddings_on_embedding (embedding) USING ivfflat
#
class ArticleEmbedding < ApplicationRecord
belongs_to :article
has_neighbors :embedding, normalize: true
after_commit :update_response_embedding
delegate :account_id, to: :article
private
def update_response_embedding
return unless saved_change_to_term? || embedding.nil?
Captain::Llm::UpdateEmbeddingJob.perform_later(self, term)
end
end

View File

@@ -0,0 +1,121 @@
# == Schema Information
#
# Table name: captain_assistants
#
# id :bigint not null, primary key
# config :jsonb not null
# description :string
# 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
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 :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
store_accessor :config, :temperature, :feature_faq, :feature_memory, :product_name
validates :name, presence: true
validates :description, presence: true
validates :account_id, presence: true
scope :ordered, -> { order(created_at: :desc) }
scope :for_account, ->(account_id) { where(account_id: account_id) }
def available_name
name
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
private
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)
]
end
def prompt_context
{
name: name,
description: description,
product_name: config['product_name'] || 'this product',
scenarios: scenarios.enabled.map do |scenario|
{
title: scenario.title,
key: scenario.title.parameterize.underscore,
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

View File

@@ -0,0 +1,67 @@
# == Schema Information
#
# Table name: captain_assistant_responses
#
# id :bigint not null, primary key
# answer :text not null
# documentable_type :string
# embedding :vector(1536)
# question :string not null
# status :integer default("approved"), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# assistant_id :bigint not null
# documentable_id :bigint
#
# Indexes
#
# idx_cap_asst_resp_on_documentable (documentable_id,documentable_type)
# index_captain_assistant_responses_on_account_id (account_id)
# index_captain_assistant_responses_on_assistant_id (assistant_id)
# index_captain_assistant_responses_on_status (status)
# vector_idx_knowledge_entries_embedding (embedding) USING ivfflat
#
class Captain::AssistantResponse < ApplicationRecord
self.table_name = 'captain_assistant_responses'
belongs_to :assistant, class_name: 'Captain::Assistant'
belongs_to :account
belongs_to :documentable, polymorphic: true, optional: true
has_neighbors :embedding, normalize: true
validates :question, presence: true
validates :answer, presence: true
before_validation :ensure_account
before_validation :ensure_status
after_commit :update_response_embedding
scope :ordered, -> { order(created_at: :desc) }
scope :by_account, ->(account_id) { where(account_id: account_id) }
scope :by_assistant, ->(assistant_id) { where(assistant_id: assistant_id) }
scope :with_document, ->(document_id) { where(document_id: document_id) }
enum status: { pending: 0, approved: 1 }
def self.search(query, account_id: nil)
embedding = Captain::Llm::EmbeddingService.new(account_id: account_id).get_embedding(query)
nearest_neighbors(:embedding, embedding, distance: 'cosine').limit(5)
end
private
def ensure_status
self.status ||= :approved
end
def ensure_account
self.account = assistant&.account
end
def update_response_embedding
return unless saved_change_to_question? || saved_change_to_answer? || embedding.nil?
Captain::Llm::UpdateEmbeddingJob.perform_later(self, "#{question}: #{answer}")
end
end

View File

@@ -0,0 +1,100 @@
# == Schema Information
#
# Table name: captain_custom_tools
#
# id :bigint not null, primary key
# auth_config :jsonb
# auth_type :string default("none")
# description :text
# enabled :boolean default(TRUE), not null
# endpoint_url :text not null
# http_method :string default("GET"), not null
# param_schema :jsonb
# request_template :text
# response_template :text
# slug :string not null
# title :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
# index_captain_custom_tools_on_account_id (account_id)
# index_captain_custom_tools_on_account_id_and_slug (account_id,slug) UNIQUE
#
class Captain::CustomTool < ApplicationRecord
include Concerns::Toolable
include Concerns::SafeEndpointValidatable
self.table_name = 'captain_custom_tools'
NAME_PREFIX = 'custom'.freeze
NAME_SEPARATOR = '_'.freeze
PARAM_SCHEMA_VALIDATION = {
'type': 'array',
'items': {
'type': 'object',
'properties': {
'name': { 'type': 'string' },
'type': { 'type': 'string' },
'description': { 'type': 'string' },
'required': { 'type': 'boolean' }
},
'required': %w[name type description],
'additionalProperties': false
}
}.to_json.freeze
belongs_to :account
enum :http_method, %w[GET POST].index_by(&:itself), validate: true
enum :auth_type, %w[none bearer basic api_key].index_by(&:itself), default: :none, validate: true, prefix: :auth
before_validation :generate_slug
validates :slug, presence: true, uniqueness: { scope: :account_id }
validates :title, presence: true
validates :endpoint_url, presence: true
validates_with JsonSchemaValidator,
schema: PARAM_SCHEMA_VALIDATION,
attribute_resolver: ->(record) { record.param_schema }
scope :enabled, -> { where(enabled: true) }
def to_tool_metadata
{
id: slug,
title: title,
description: description,
custom: true
}
end
private
def generate_slug
return if slug.present?
return if title.blank?
paramterized_title = title.parameterize(separator: NAME_SEPARATOR)
base_slug = "#{NAME_PREFIX}#{NAME_SEPARATOR}#{paramterized_title}"
self.slug = find_unique_slug(base_slug)
end
def find_unique_slug(base_slug)
return base_slug unless slug_exists?(base_slug)
5.times do
slug_candidate = "#{base_slug}#{NAME_SEPARATOR}#{SecureRandom.alphanumeric(6).downcase}"
return slug_candidate unless slug_exists?(slug_candidate)
end
raise ActiveRecord::RecordNotUnique, I18n.t('captain.custom_tool.slug_generation_failed')
end
def slug_exists?(candidate)
self.class.exists?(account_id: account_id, slug: candidate)
end
end

View File

@@ -0,0 +1,154 @@
# == Schema Information
#
# Table name: captain_documents
#
# id :bigint not null, primary key
# content :text
# external_link :string not null
# metadata :jsonb
# name :string
# status :integer default("in_progress"), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# assistant_id :bigint not null
#
# Indexes
#
# index_captain_documents_on_account_id (account_id)
# index_captain_documents_on_assistant_id (assistant_id)
# index_captain_documents_on_assistant_id_and_external_link (assistant_id,external_link) UNIQUE
# index_captain_documents_on_status (status)
#
class Captain::Document < ApplicationRecord
class LimitExceededError < StandardError; end
self.table_name = 'captain_documents'
belongs_to :assistant, class_name: 'Captain::Assistant'
has_many :responses, class_name: 'Captain::AssistantResponse', dependent: :destroy, as: :documentable
belongs_to :account
has_one_attached :pdf_file
validates :external_link, presence: true, unless: -> { pdf_file.attached? }
validates :external_link, uniqueness: { scope: :assistant_id }, allow_blank: true
validates :content, length: { maximum: 200_000 }
validates :pdf_file, presence: true, if: :pdf_document?
validate :validate_pdf_format, if: :pdf_document?
validate :validate_file_attachment, if: -> { pdf_file.attached? }
before_validation :ensure_account_id
before_validation :set_external_link_for_pdf
before_validation :normalize_external_link
enum status: {
in_progress: 0,
available: 1
}
before_create :ensure_within_plan_limit
after_create_commit :enqueue_crawl_job
after_create_commit :update_document_usage
after_destroy :update_document_usage
after_commit :enqueue_response_builder_job
scope :ordered, -> { order(created_at: :desc) }
scope :for_account, ->(account_id) { where(account_id: account_id) }
scope :for_assistant, ->(assistant_id) { where(assistant_id: assistant_id) }
def pdf_document?
return true if pdf_file.attached? && pdf_file.blob.content_type == 'application/pdf'
external_link&.ends_with?('.pdf')
end
def content_type
pdf_file.blob.content_type if pdf_file.attached?
end
def file_size
pdf_file.blob.byte_size if pdf_file.attached?
end
def openai_file_id
metadata&.dig('openai_file_id')
end
def store_openai_file_id(file_id)
update!(metadata: (metadata || {}).merge('openai_file_id' => file_id))
end
def display_url
return external_link if external_link.present? && !external_link.start_with?('PDF:')
if pdf_file.attached?
Rails.application.routes.url_helpers.rails_blob_url(pdf_file, only_path: false)
else
external_link
end
end
private
def enqueue_crawl_job
return if status != 'in_progress'
Captain::Documents::CrawlJob.perform_later(self)
end
def enqueue_response_builder_job
return unless should_enqueue_response_builder?
Captain::Documents::ResponseBuilderJob.perform_later(self)
end
def should_enqueue_response_builder?
return false if destroyed?
return false unless available?
return saved_change_to_status? if pdf_document?
(saved_change_to_status? || saved_change_to_content?) && content.present?
end
def update_document_usage
account.update_document_usage
end
def ensure_account_id
self.account_id = assistant&.account_id
end
def ensure_within_plan_limit
limits = account.usage_limits[:captain][:documents]
raise LimitExceededError, I18n.t('captain.documents.limit_exceeded') unless limits[:current_available].positive?
end
def validate_pdf_format
return unless pdf_file.attached?
errors.add(:pdf_file, I18n.t('captain.documents.pdf_format_error')) unless pdf_file.blob.content_type == 'application/pdf'
end
def validate_file_attachment
return unless pdf_file.attached?
return unless pdf_file.blob.byte_size > 10.megabytes
errors.add(:pdf_file, I18n.t('captain.documents.pdf_size_error'))
end
def set_external_link_for_pdf
return unless pdf_file.attached? && external_link.blank?
# Set a unique external_link for PDF files
# Format: PDF: filename_timestamp (without extension)
timestamp = Time.current.strftime('%Y%m%d%H%M%S')
self.external_link = "PDF: #{pdf_file.filename.base}_#{timestamp}"
end
def normalize_external_link
return if external_link.blank?
return if pdf_document?
self.external_link = external_link.delete_suffix('/')
end
end

View File

@@ -0,0 +1,139 @@
# == Schema Information
#
# Table name: captain_scenarios
#
# id :bigint not null, primary key
# description :text
# enabled :boolean default(TRUE), not null
# instruction :text
# title :string
# tools :jsonb
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# assistant_id :bigint not null
#
# Indexes
#
# index_captain_scenarios_on_account_id (account_id)
# index_captain_scenarios_on_assistant_id (assistant_id)
# index_captain_scenarios_on_assistant_id_and_enabled (assistant_id,enabled)
# index_captain_scenarios_on_enabled (enabled)
#
class Captain::Scenario < ApplicationRecord
include Concerns::CaptainToolsHelpers
include Concerns::Agentable
self.table_name = 'captain_scenarios'
belongs_to :assistant, class_name: 'Captain::Assistant'
belongs_to :account
validates :title, presence: true
validates :description, presence: true
validates :instruction, presence: true
validates :assistant_id, presence: true
validates :account_id, presence: true
validate :validate_instruction_tools
scope :enabled, -> { where(enabled: true) }
delegate :temperature, :feature_faq, :feature_memory, :product_name, :response_guidelines, :guardrails, to: :assistant
before_save :resolve_tool_references
def prompt_context
{
title: title,
instructions: resolved_instructions,
tools: resolved_tools,
assistant_name: assistant.name.downcase.gsub(/\s+/, '_'),
response_guidelines: response_guidelines || [],
guardrails: guardrails || []
}
end
private
def agent_name
"#{title} Agent".parameterize(separator: '_')
end
def agent_tools
resolved_tools.map { |tool| resolve_tool_instance(tool) }
end
def resolved_instructions
instruction.gsub(TOOL_REFERENCE_REGEX, '`\1` tool')
end
def resolved_tools
return [] if tools.blank?
available_tools = assistant.available_agent_tools
tools.filter_map do |tool_id|
available_tools.find { |tool| tool[:id] == tool_id }
end
end
def resolve_tool_instance(tool_metadata)
tool_id = tool_metadata[:id]
if tool_metadata[:custom]
custom_tool = Captain::CustomTool.find_by(slug: tool_id, account_id: account_id, enabled: true)
custom_tool&.tool(assistant)
else
tool_class = self.class.resolve_tool_class(tool_id)
tool_class&.new(assistant)
end
end
# Validates that all tool references in the instruction are valid.
# Parses the instruction for tool references and checks if they exist
# in the available tools configuration.
#
# @return [void]
# @api private
# @example Valid instruction
# scenario.instruction = "Use [Add Contact Note](tool://add_contact_note) to document"
# scenario.valid? # => true
#
# @example Invalid instruction
# scenario.instruction = "Use [Invalid Tool](tool://invalid_tool) to process"
# scenario.valid? # => false
# scenario.errors[:instruction] # => ["contains invalid tools: invalid_tool"]
def validate_instruction_tools
return if instruction.blank?
tool_ids = extract_tool_ids_from_text(instruction)
return if tool_ids.empty?
all_available_tool_ids = assistant.available_tool_ids
invalid_tools = tool_ids - all_available_tool_ids
return unless invalid_tools.any?
errors.add(:instruction, "contains invalid tools: #{invalid_tools.join(', ')}")
end
# Resolves tool references from the instruction text into the tools field.
# Parses the instruction for tool references and materializes them as
# tool IDs stored in the tools JSONB field.
#
# @return [void]
# @api private
# @example
# scenario.instruction = "First [@Add Private Note](tool://add_private_note) then [@Update Priority](tool://update_priority)"
# scenario.save!
# scenario.tools # => ["add_private_note", "update_priority"]
#
# scenario.instruction = "No tools mentioned here"
# scenario.save!
# scenario.tools # => nil
def resolve_tool_references
return if instruction.blank?
tool_ids = extract_tool_ids_from_text(instruction)
self.tools = tool_ids.presence
end
end

View File

@@ -0,0 +1,22 @@
# == Schema Information
#
# Table name: captain_inboxes
#
# id :bigint not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
# captain_assistant_id :bigint not null
# inbox_id :bigint not null
#
# Indexes
#
# index_captain_inboxes_on_captain_assistant_id (captain_assistant_id)
# index_captain_inboxes_on_captain_assistant_id_and_inbox_id (captain_assistant_id,inbox_id) UNIQUE
# index_captain_inboxes_on_inbox_id (inbox_id)
#
class CaptainInbox < ApplicationRecord
belongs_to :captain_assistant, class_name: 'Captain::Assistant'
belongs_to :inbox
validates :inbox_id, uniqueness: true
end

View File

@@ -0,0 +1,122 @@
# == Schema Information
#
# Table name: channel_voice
#
# id :bigint not null, primary key
# additional_attributes :jsonb
# phone_number :string not null
# provider :string default("twilio"), not null
# provider_config :jsonb not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
#
# Indexes
#
# index_channel_voice_on_account_id (account_id)
# index_channel_voice_on_phone_number (phone_number) UNIQUE
#
class Channel::Voice < ApplicationRecord
include Channelable
self.table_name = 'channel_voice'
validates :phone_number, presence: true, uniqueness: true
validates :provider, presence: true
validates :provider_config, presence: true
# Validate phone number format (E.164 format)
validates :phone_number, format: { with: /\A\+[1-9]\d{1,14}\z/ }
# Provider-specific configs stored in JSON
validate :validate_provider_config
before_validation :provision_twilio_on_create, on: :create, if: :twilio?
EDITABLE_ATTRS = [:phone_number, :provider, { provider_config: {} }].freeze
def name
"Voice (#{phone_number})"
end
def messaging_window_enabled?
false
end
def initiate_call(to:, conference_sid: nil, agent_id: nil)
case provider
when 'twilio'
Voice::Provider::Twilio::Adapter.new(self).initiate_call(
to: to,
conference_sid: conference_sid,
agent_id: agent_id
)
else
raise "Unsupported voice provider: #{provider}"
end
end
# Public URLs used to configure Twilio webhooks
def voice_call_webhook_url
digits = phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
end
def voice_status_webhook_url
digits = phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_status_url(phone: digits)
end
private
def twilio?
provider == 'twilio'
end
def validate_provider_config
return if provider_config.blank?
case provider
when 'twilio'
validate_twilio_config
end
end
def validate_twilio_config
config = provider_config.with_indifferent_access
# Require credentials and provisioned TwiML App SID
required_keys = %w[account_sid auth_token api_key_sid api_key_secret twiml_app_sid]
required_keys.each do |key|
errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
end
end
def provider_config_hash
if provider_config.is_a?(Hash)
provider_config
else
JSON.parse(provider_config.to_s)
end
end
def provision_twilio_on_create
service = ::Twilio::VoiceWebhookSetupService.new(channel: self)
app_sid = service.perform
return if app_sid.blank?
cfg = provider_config.with_indifferent_access
cfg[:twiml_app_sid] = app_sid
self.provider_config = cfg
rescue StandardError => e
error_details = {
error_class: e.class.to_s,
message: e.message,
phone_number: phone_number,
account_id: account_id,
backtrace: e.backtrace&.first(5)
}
Rails.logger.error("TWILIO_VOICE_SETUP_ON_CREATE_ERROR: #{error_details}")
errors.add(:base, "Twilio setup failed: #{e.message}")
end
public :provider_config_hash
end

Some files were not shown because too many files have changed in this diff Show More