Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
36
research/chatwoot/enterprise/LICENSE
Normal file
36
research/chatwoot/enterprise/LICENSE
Normal 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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
109
research/chatwoot/enterprise/app/builders/saml_user_builder.rb
Normal file
109
research/chatwoot/enterprise/app/builders/saml_user_builder.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
class Api::V1::Accounts::EnterpriseAccountsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
module Enterprise::Api::V1::AccountsSettings
|
||||
private
|
||||
|
||||
def permitted_settings_attributes
|
||||
super + [{ conversation_required_attributes: [] }]
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
module Enterprise::Concerns::ApplicationControllerConcern
|
||||
extend ActiveSupport::Concern
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
class SuperAdmin::EnterpriseBaseController < SuperAdmin::ApplicationController
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
module Enterprise::AsyncDispatcher
|
||||
def listeners
|
||||
super + [
|
||||
CaptainListener.instance
|
||||
]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class SlaPolicyDrop < BaseDrop
|
||||
def name
|
||||
@obj.try(:name)
|
||||
end
|
||||
|
||||
def description
|
||||
@obj.try(:description)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
require 'administrate/field/base'
|
||||
|
||||
class AccountFeaturesField < Administrate::Field::Base
|
||||
def to_s
|
||||
data
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
136
research/chatwoot/enterprise/app/helpers/captain/chat_helper.rb
Normal file
136
research/chatwoot/enterprise/app/helpers/captain/chat_helper.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
class Enterprise::CreateStripeCustomerJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(account)
|
||||
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
module Enterprise::TriggerScheduledItemsJob
|
||||
def perform
|
||||
super
|
||||
|
||||
## Triggers Enterprise specific jobs
|
||||
####################################
|
||||
|
||||
# Triggers Account Sla jobs
|
||||
Sla::TriggerSlasForAccountsJob.perform_later
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
class Portal::ArticleIndexingJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(article)
|
||||
article.generate_and_save_article_seach_terms
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
77
research/chatwoot/enterprise/app/models/applied_sla.rb
Normal file
77
research/chatwoot/enterprise/app/models/applied_sla.rb
Normal 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
|
||||
31
research/chatwoot/enterprise/app/models/article_embedding.rb
Normal file
31
research/chatwoot/enterprise/app/models/article_embedding.rb
Normal 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
|
||||
121
research/chatwoot/enterprise/app/models/captain/assistant.rb
Normal file
121
research/chatwoot/enterprise/app/models/captain/assistant.rb
Normal 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
|
||||
@@ -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
|
||||
100
research/chatwoot/enterprise/app/models/captain/custom_tool.rb
Normal file
100
research/chatwoot/enterprise/app/models/captain/custom_tool.rb
Normal 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
|
||||
154
research/chatwoot/enterprise/app/models/captain/document.rb
Normal file
154
research/chatwoot/enterprise/app/models/captain/document.rb
Normal 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
|
||||
139
research/chatwoot/enterprise/app/models/captain/scenario.rb
Normal file
139
research/chatwoot/enterprise/app/models/captain/scenario.rb
Normal 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
|
||||
22
research/chatwoot/enterprise/app/models/captain_inbox.rb
Normal file
22
research/chatwoot/enterprise/app/models/captain_inbox.rb
Normal 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
|
||||
122
research/chatwoot/enterprise/app/models/channel/voice.rb
Normal file
122
research/chatwoot/enterprise/app/models/channel/voice.rb
Normal 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
Reference in New Issue
Block a user