Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
@@ -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
|
||||
45
research/chatwoot/enterprise/app/models/company.rb
Normal file
45
research/chatwoot/enterprise/app/models/company.rb
Normal file
@@ -0,0 +1,45 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: companies
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# contacts_count :integer
|
||||
# description :text
|
||||
# domain :string
|
||||
# name :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_companies_on_account_and_domain (account_id,domain) UNIQUE WHERE (domain IS NOT NULL)
|
||||
# index_companies_on_account_id (account_id)
|
||||
# index_companies_on_name_and_account_id (name,account_id)
|
||||
#
|
||||
class Company < ApplicationRecord
|
||||
include Avatarable
|
||||
validates :account_id, presence: true
|
||||
validates :name, presence: true, length: { maximum: Limits::COMPANY_NAME_LENGTH_LIMIT }
|
||||
validates :domain, allow_blank: true, format: {
|
||||
with: /\A[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+\z/,
|
||||
message: I18n.t('errors.companies.domain.invalid')
|
||||
}
|
||||
validates :domain, uniqueness: { scope: :account_id }, if: -> { domain.present? }
|
||||
validates :description, length: { maximum: Limits::COMPANY_DESCRIPTION_LENGTH_LIMIT }
|
||||
|
||||
belongs_to :account
|
||||
has_many :contacts, dependent: :nullify
|
||||
|
||||
scope :ordered_by_name, -> { order(:name) }
|
||||
scope :search_by_name_or_domain, lambda { |query|
|
||||
where('name ILIKE :search OR domain ILIKE :search', search: "%#{query.strip}%")
|
||||
}
|
||||
scope :order_on_contacts_count, lambda { |direction|
|
||||
order(
|
||||
Arel::Nodes::SqlLiteral.new(
|
||||
sanitize_sql_for_order("\"companies\".\"contacts_count\" #{direction} NULLS LAST")
|
||||
)
|
||||
)
|
||||
}
|
||||
end
|
||||
@@ -0,0 +1,56 @@
|
||||
module Concerns::Agentable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def agent
|
||||
Agents::Agent.new(
|
||||
name: agent_name,
|
||||
instructions: ->(context) { agent_instructions(context) },
|
||||
tools: agent_tools,
|
||||
model: agent_model,
|
||||
temperature: temperature.to_f || 0.7,
|
||||
response_schema: agent_response_schema
|
||||
)
|
||||
end
|
||||
|
||||
def agent_instructions(context = nil)
|
||||
enhanced_context = prompt_context
|
||||
|
||||
if context
|
||||
state = context.context[:state] || {}
|
||||
conversation_data = state[:conversation] || {}
|
||||
contact_data = state[:contact] || {}
|
||||
enhanced_context = enhanced_context.merge(
|
||||
conversation: conversation_data,
|
||||
contact: contact_data
|
||||
)
|
||||
end
|
||||
|
||||
Captain::PromptRenderer.render(template_name, enhanced_context.with_indifferent_access)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def agent_name
|
||||
raise NotImplementedError, "#{self.class} must implement agent_name"
|
||||
end
|
||||
|
||||
def template_name
|
||||
self.class.name.demodulize.underscore
|
||||
end
|
||||
|
||||
def agent_tools
|
||||
[] # Default implementation, override if needed
|
||||
end
|
||||
|
||||
def agent_model
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || LlmConstants::DEFAULT_MODEL
|
||||
end
|
||||
|
||||
def agent_response_schema
|
||||
Captain::ResponseSchema
|
||||
end
|
||||
|
||||
def prompt_context
|
||||
raise NotImplementedError, "#{self.class} must implement prompt_context"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,76 @@
|
||||
# Provides helper methods for working with Captain agent tools including
|
||||
# tool resolution, text parsing, and metadata retrieval.
|
||||
module Concerns::CaptainToolsHelpers
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# Regular expression pattern for matching tool references in text.
|
||||
# Matches patterns like [Tool name](tool://tool_id) following markdown link syntax.
|
||||
TOOL_REFERENCE_REGEX = %r{\[[^\]]+\]\(tool://([^/)]+)\)}
|
||||
|
||||
class_methods do
|
||||
# Returns all built-in agent tools with their metadata.
|
||||
# Only includes tools that have corresponding class files and can be resolved.
|
||||
#
|
||||
# @return [Array<Hash>] Array of tool hashes with :id, :title, :description, :icon
|
||||
def built_in_agent_tools
|
||||
@built_in_agent_tools ||= load_agent_tools
|
||||
end
|
||||
|
||||
# Resolves a tool class from a tool ID.
|
||||
# Converts snake_case tool IDs to PascalCase class names and constantizes them.
|
||||
#
|
||||
# @param tool_id [String] The snake_case tool identifier
|
||||
# @return [Class, nil] The tool class if found, nil if not resolvable
|
||||
def resolve_tool_class(tool_id)
|
||||
class_name = "Captain::Tools::#{tool_id.classify}Tool"
|
||||
class_name.safe_constantize
|
||||
end
|
||||
|
||||
# Returns an array of all built-in tool IDs.
|
||||
# Convenience method that extracts just the IDs from built_in_agent_tools.
|
||||
#
|
||||
# @return [Array<String>] Array of built-in tool IDs
|
||||
def built_in_tool_ids
|
||||
@built_in_tool_ids ||= built_in_agent_tools.map { |tool| tool[:id] }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Loads agent tools from the YAML configuration file.
|
||||
# Filters out tools that cannot be resolved to actual classes.
|
||||
#
|
||||
# @return [Array<Hash>] Array of resolvable tools with metadata
|
||||
# @api private
|
||||
def load_agent_tools
|
||||
tools_config = YAML.load_file(Rails.root.join('config/agents/tools.yml'))
|
||||
|
||||
tools_config.filter_map do |tool_config|
|
||||
tool_class = resolve_tool_class(tool_config['id'])
|
||||
|
||||
if tool_class
|
||||
{
|
||||
id: tool_config['id'],
|
||||
title: tool_config['title'],
|
||||
description: tool_config['description'],
|
||||
icon: tool_config['icon']
|
||||
}
|
||||
else
|
||||
Rails.logger.warn "Tool class not found for ID: #{tool_config['id']}"
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Extracts tool IDs from text containing tool references.
|
||||
# Parses text for (tool://tool_id) patterns and returns unique tool IDs.
|
||||
#
|
||||
# @param text [String] Text to parse for tool references
|
||||
# @return [Array<String>] Array of unique tool IDs found in the text
|
||||
def extract_tool_ids_from_text(text)
|
||||
return [] if text.blank?
|
||||
|
||||
tool_matches = text.scan(TOOL_REFERENCE_REGEX)
|
||||
tool_matches.flatten.uniq
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,84 @@
|
||||
module Concerns::SafeEndpointValidatable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
FRONTEND_HOST = URI.parse(ENV.fetch('FRONTEND_URL', 'http://localhost:3000')).host.freeze
|
||||
DISALLOWED_HOSTS = ['localhost', /\.local\z/i].freeze
|
||||
|
||||
included do
|
||||
validate :validate_safe_endpoint_url
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_safe_endpoint_url
|
||||
return if endpoint_url.blank?
|
||||
|
||||
uri = parse_endpoint_uri
|
||||
return errors.add(:endpoint_url, 'must be a valid URL') unless uri
|
||||
|
||||
validate_endpoint_scheme(uri)
|
||||
validate_endpoint_host(uri)
|
||||
validate_not_ip_address(uri)
|
||||
validate_no_unicode_chars(uri)
|
||||
end
|
||||
|
||||
def parse_endpoint_uri
|
||||
# Strip Liquid template syntax for validation
|
||||
# Replace {{ variable }} with a placeholder value
|
||||
sanitized_url = endpoint_url.gsub(/\{\{[^}]+\}\}/, 'placeholder')
|
||||
URI.parse(sanitized_url)
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
|
||||
def validate_endpoint_scheme(uri)
|
||||
return if uri.scheme == 'https'
|
||||
|
||||
errors.add(:endpoint_url, 'must use HTTPS protocol')
|
||||
end
|
||||
|
||||
def validate_endpoint_host(uri)
|
||||
if uri.host.blank?
|
||||
errors.add(:endpoint_url, 'must have a valid hostname')
|
||||
return
|
||||
end
|
||||
|
||||
if uri.host == FRONTEND_HOST
|
||||
errors.add(:endpoint_url, 'cannot point to the application itself')
|
||||
return
|
||||
end
|
||||
|
||||
DISALLOWED_HOSTS.each do |pattern|
|
||||
matched = if pattern.is_a?(Regexp)
|
||||
uri.host =~ pattern
|
||||
else
|
||||
uri.host.downcase == pattern
|
||||
end
|
||||
|
||||
next unless matched
|
||||
|
||||
errors.add(:endpoint_url, 'cannot use disallowed hostname')
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
def validate_not_ip_address(uri)
|
||||
# Check for IPv4
|
||||
if /\A\d+\.\d+\.\d+\.\d+\z/.match?(uri.host)
|
||||
errors.add(:endpoint_url, 'cannot be an IP address, must be a hostname')
|
||||
return
|
||||
end
|
||||
|
||||
# Check for IPv6
|
||||
return unless uri.host.include?(':')
|
||||
|
||||
errors.add(:endpoint_url, 'cannot be an IP address, must be a hostname')
|
||||
end
|
||||
|
||||
def validate_no_unicode_chars(uri)
|
||||
return unless uri.host
|
||||
return if /\A[\x00-\x7F]+\z/.match?(uri.host)
|
||||
|
||||
errors.add(:endpoint_url, 'hostname cannot contain non-ASCII characters')
|
||||
end
|
||||
end
|
||||
116
research/chatwoot/enterprise/app/models/concerns/toolable.rb
Normal file
116
research/chatwoot/enterprise/app/models/concerns/toolable.rb
Normal file
@@ -0,0 +1,116 @@
|
||||
module Concerns::Toolable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def tool(assistant)
|
||||
custom_tool_record = self
|
||||
# Convert slug to valid Ruby constant name (replace hyphens with underscores, then camelize)
|
||||
class_name = custom_tool_record.slug.underscore.camelize
|
||||
|
||||
# Always create a fresh class to reflect current metadata
|
||||
tool_class = Class.new(Captain::Tools::HttpTool) do
|
||||
description custom_tool_record.description
|
||||
|
||||
custom_tool_record.param_schema.each do |param_def|
|
||||
param param_def['name'].to_sym,
|
||||
type: param_def['type'],
|
||||
desc: param_def['description'],
|
||||
required: param_def.fetch('required', true)
|
||||
end
|
||||
end
|
||||
|
||||
# Register the dynamically created class as a constant in the Captain::Tools namespace.
|
||||
# This is required because RubyLLM's Tool base class derives the tool name from the class name
|
||||
# (via Class#name). Anonymous classes created with Class.new have no name and return empty strings,
|
||||
# which causes "Invalid 'tools[].function.name': empty string" errors from the LLM API.
|
||||
# By setting it as a constant, the class gets a proper name (e.g., "Captain::Tools::CatFactLookup")
|
||||
# which RubyLLM extracts and normalizes to "cat-fact-lookup" for the LLM API.
|
||||
# We refresh the constant on each call to ensure tool metadata changes are reflected.
|
||||
Captain::Tools.send(:remove_const, class_name) if Captain::Tools.const_defined?(class_name, false)
|
||||
Captain::Tools.const_set(class_name, tool_class)
|
||||
|
||||
tool_class.new(assistant, self)
|
||||
end
|
||||
|
||||
def build_request_url(params)
|
||||
return endpoint_url if endpoint_url.blank? || endpoint_url.exclude?('{{')
|
||||
|
||||
render_template(endpoint_url, params)
|
||||
end
|
||||
|
||||
def build_request_body(params)
|
||||
return nil if request_template.blank?
|
||||
|
||||
render_template(request_template, params)
|
||||
end
|
||||
|
||||
def build_auth_headers
|
||||
return {} if auth_none?
|
||||
|
||||
case auth_type
|
||||
when 'bearer'
|
||||
{ 'Authorization' => "Bearer #{auth_config['token']}" }
|
||||
when 'api_key'
|
||||
if auth_config['location'] == 'header'
|
||||
{ auth_config['name'] => auth_config['key'] }
|
||||
else
|
||||
{}
|
||||
end
|
||||
else
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
def build_basic_auth_credentials
|
||||
return nil unless auth_type == 'basic'
|
||||
|
||||
[auth_config['username'], auth_config['password']]
|
||||
end
|
||||
|
||||
def build_metadata_headers(state)
|
||||
{}.tap do |headers|
|
||||
add_base_headers(headers, state)
|
||||
add_conversation_headers(headers, state[:conversation]) if state[:conversation]
|
||||
add_contact_headers(headers, state[:contact]) if state[:contact]
|
||||
end
|
||||
end
|
||||
|
||||
def add_base_headers(headers, state)
|
||||
headers['X-Chatwoot-Account-Id'] = state[:account_id].to_s if state[:account_id]
|
||||
headers['X-Chatwoot-Assistant-Id'] = state[:assistant_id].to_s if state[:assistant_id]
|
||||
headers['X-Chatwoot-Tool-Slug'] = slug if slug.present?
|
||||
end
|
||||
|
||||
def add_conversation_headers(headers, conversation)
|
||||
headers['X-Chatwoot-Conversation-Id'] = conversation[:id].to_s if conversation[:id]
|
||||
headers['X-Chatwoot-Conversation-Display-Id'] = conversation[:display_id].to_s if conversation[:display_id]
|
||||
end
|
||||
|
||||
def add_contact_headers(headers, contact)
|
||||
headers['X-Chatwoot-Contact-Id'] = contact[:id].to_s if contact[:id]
|
||||
headers['X-Chatwoot-Contact-Email'] = contact[:email].to_s if contact[:email].present?
|
||||
headers['X-Chatwoot-Contact-Phone'] = contact[:phone_number].to_s if contact[:phone_number].present?
|
||||
end
|
||||
|
||||
def format_response(raw_response_body)
|
||||
return raw_response_body if response_template.blank?
|
||||
|
||||
response_data = parse_response_body(raw_response_body)
|
||||
render_template(response_template, { 'response' => response_data, 'r' => response_data })
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_template(template, context)
|
||||
liquid_template = Liquid::Template.parse(template, error_mode: :strict)
|
||||
liquid_template.render(context.deep_stringify_keys, registers: {}, strict_variables: true, strict_filters: true)
|
||||
rescue Liquid::SyntaxError, Liquid::UndefinedVariable, Liquid::UndefinedFilter => e
|
||||
Rails.logger.error("Liquid template error: #{e.message}")
|
||||
raise "Template rendering failed: #{e.message}"
|
||||
end
|
||||
|
||||
def parse_response_body(body)
|
||||
JSON.parse(body)
|
||||
rescue JSON::ParserError
|
||||
body
|
||||
end
|
||||
end
|
||||
68
research/chatwoot/enterprise/app/models/copilot_message.rb
Normal file
68
research/chatwoot/enterprise/app/models/copilot_message.rb
Normal file
@@ -0,0 +1,68 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: copilot_messages
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# message :jsonb not null
|
||||
# message_type :integer default("user")
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# copilot_thread_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_copilot_messages_on_account_id (account_id)
|
||||
# index_copilot_messages_on_copilot_thread_id (copilot_thread_id)
|
||||
#
|
||||
class CopilotMessage < ApplicationRecord
|
||||
belongs_to :copilot_thread
|
||||
belongs_to :account
|
||||
|
||||
enum message_type: { user: 0, assistant: 1, assistant_thinking: 2 }
|
||||
|
||||
validates :message_type, presence: true
|
||||
validates :message, presence: true
|
||||
before_validation :ensure_account
|
||||
validate :validate_message_attributes
|
||||
after_create_commit :broadcast_message
|
||||
|
||||
def push_event_data
|
||||
{
|
||||
id: id,
|
||||
message: message,
|
||||
message_type: message_type,
|
||||
created_at: created_at.to_i,
|
||||
copilot_thread: copilot_thread.push_event_data
|
||||
}
|
||||
end
|
||||
|
||||
def enqueue_response_job(conversation_id, user_id)
|
||||
Captain::Copilot::ResponseJob.perform_later(
|
||||
assistant: copilot_thread.assistant,
|
||||
conversation_id: conversation_id,
|
||||
user_id: user_id,
|
||||
copilot_thread_id: copilot_thread.id,
|
||||
message: message['content']
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_account
|
||||
self.account_id = copilot_thread&.account_id
|
||||
end
|
||||
|
||||
def broadcast_message
|
||||
Rails.configuration.dispatcher.dispatch(COPILOT_MESSAGE_CREATED, Time.zone.now, copilot_message: self)
|
||||
end
|
||||
|
||||
def validate_message_attributes
|
||||
return if message.blank?
|
||||
|
||||
allowed_keys = %w[content reasoning function_name reply_suggestion]
|
||||
invalid_keys = message.keys - allowed_keys
|
||||
|
||||
errors.add(:message, "contains invalid attributes: #{invalid_keys.join(', ')}") if invalid_keys.any?
|
||||
end
|
||||
end
|
||||
48
research/chatwoot/enterprise/app/models/copilot_thread.rb
Normal file
48
research/chatwoot/enterprise/app/models/copilot_thread.rb
Normal file
@@ -0,0 +1,48 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: copilot_threads
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# title :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# assistant_id :integer
|
||||
# user_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_copilot_threads_on_account_id (account_id)
|
||||
# index_copilot_threads_on_assistant_id (assistant_id)
|
||||
# index_copilot_threads_on_user_id (user_id)
|
||||
#
|
||||
class CopilotThread < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :account
|
||||
belongs_to :assistant, class_name: 'Captain::Assistant'
|
||||
has_many :copilot_messages, dependent: :destroy_async
|
||||
|
||||
validates :title, presence: true
|
||||
|
||||
def push_event_data
|
||||
{
|
||||
id: id,
|
||||
title: title,
|
||||
created_at: created_at.to_i,
|
||||
user: user.push_event_data,
|
||||
account_id: account_id
|
||||
}
|
||||
end
|
||||
|
||||
def previous_history
|
||||
copilot_messages
|
||||
.where(message_type: %w[user assistant])
|
||||
.order(created_at: :asc)
|
||||
.map do |copilot_message|
|
||||
{
|
||||
content: copilot_message.message['content'],
|
||||
role: copilot_message.message_type
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
42
research/chatwoot/enterprise/app/models/custom_role.rb
Normal file
42
research/chatwoot/enterprise/app/models/custom_role.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: custom_roles
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# description :string
|
||||
# name :string
|
||||
# permissions :text default([]), is an Array
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_custom_roles_on_account_id (account_id)
|
||||
#
|
||||
#
|
||||
|
||||
# Available permissions for custom roles:
|
||||
# - 'conversation_manage': Can manage all conversations.
|
||||
# - 'conversation_unassigned_manage': Can manage unassigned conversations and assign to self.
|
||||
# - 'conversation_participating_manage': Can manage conversations they are participating in (assigned to or a participant).
|
||||
# - 'contact_manage': Can manage contacts.
|
||||
# - 'report_manage': Can manage reports.
|
||||
# - 'knowledge_base_manage': Can manage knowledge base portals.
|
||||
|
||||
class CustomRole < ApplicationRecord
|
||||
belongs_to :account
|
||||
has_many :account_users, dependent: :nullify
|
||||
|
||||
PERMISSIONS = %w[
|
||||
conversation_manage
|
||||
conversation_unassigned_manage
|
||||
conversation_participating_manage
|
||||
contact_manage
|
||||
report_manage
|
||||
knowledge_base_manage
|
||||
].freeze
|
||||
|
||||
validates :name, presence: true
|
||||
validates :permissions, inclusion: { in: PERMISSIONS }
|
||||
end
|
||||
@@ -0,0 +1,57 @@
|
||||
module Enterprise::Account
|
||||
# TODO: Remove this when we upgrade administrate gem to the latest version
|
||||
# this is a temporary method since current administrate doesn't support virtual attributes
|
||||
def manually_managed_features; end
|
||||
|
||||
# Auto-sync advanced_assignment with assignment_v2 when features are bulk-updated via admin UI
|
||||
def selected_feature_flags=(features)
|
||||
super
|
||||
sync_assignment_features
|
||||
end
|
||||
|
||||
def mark_for_deletion(reason = 'manual_deletion')
|
||||
reason = reason.to_s == 'manual_deletion' ? 'manual_deletion' : 'inactivity'
|
||||
|
||||
result = custom_attributes.merge!(
|
||||
'marked_for_deletion_at' => 7.days.from_now.iso8601,
|
||||
'marked_for_deletion_reason' => reason
|
||||
) && save
|
||||
|
||||
# Send notification to admin users if the account was successfully marked for deletion
|
||||
if result
|
||||
mailer = AdministratorNotifications::AccountNotificationMailer.with(account: self)
|
||||
if reason == 'manual_deletion'
|
||||
mailer.account_deletion_user_initiated(self, reason).deliver_later
|
||||
else
|
||||
mailer.account_deletion_for_inactivity(self, reason).deliver_later
|
||||
end
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def unmark_for_deletion
|
||||
custom_attributes.delete('marked_for_deletion_at') && custom_attributes.delete('marked_for_deletion_reason') && save
|
||||
end
|
||||
|
||||
def saml_enabled?
|
||||
saml_settings&.saml_enabled? || false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sync_assignment_features
|
||||
if feature_enabled?('assignment_v2')
|
||||
# Enable advanced_assignment for Business/Enterprise plans
|
||||
send('feature_advanced_assignment=', true) if business_or_enterprise_plan?
|
||||
else
|
||||
# Disable advanced_assignment when assignment_v2 is disabled
|
||||
send('feature_advanced_assignment=', false)
|
||||
end
|
||||
end
|
||||
|
||||
def business_or_enterprise_plan?
|
||||
plan_name = custom_attributes['plan_name']
|
||||
%w[Business Enterprise].include?(plan_name)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,152 @@
|
||||
module Enterprise::Account::PlanUsageAndLimits # rubocop:disable Metrics/ModuleLength
|
||||
CAPTAIN_RESPONSES = 'captain_responses'.freeze
|
||||
CAPTAIN_DOCUMENTS = 'captain_documents'.freeze
|
||||
CAPTAIN_RESPONSES_USAGE = 'captain_responses_usage'.freeze
|
||||
CAPTAIN_DOCUMENTS_USAGE = 'captain_documents_usage'.freeze
|
||||
|
||||
def usage_limits
|
||||
{
|
||||
agents: agent_limits.to_i,
|
||||
inboxes: get_limits(:inboxes).to_i,
|
||||
captain: {
|
||||
documents: get_captain_limits(:documents),
|
||||
responses: get_captain_limits(:responses)
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def increment_response_usage
|
||||
current_usage = custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0
|
||||
custom_attributes[CAPTAIN_RESPONSES_USAGE] = current_usage + 1
|
||||
save
|
||||
end
|
||||
|
||||
def reset_response_usage
|
||||
custom_attributes[CAPTAIN_RESPONSES_USAGE] = 0
|
||||
save
|
||||
end
|
||||
|
||||
def update_document_usage
|
||||
# this will ensure that the document count is always accurate
|
||||
custom_attributes[CAPTAIN_DOCUMENTS_USAGE] = captain_documents.count
|
||||
save
|
||||
end
|
||||
|
||||
def email_transcript_enabled?
|
||||
default_plan = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS')&.value&.first
|
||||
return true if default_plan.blank?
|
||||
|
||||
plan_name.present? && plan_name != default_plan['name']
|
||||
end
|
||||
|
||||
def email_rate_limit
|
||||
account_limit || plan_email_limit || global_limit || default_limit
|
||||
end
|
||||
|
||||
def subscribed_features
|
||||
plan_features = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLAN_FEATURES')&.value
|
||||
return [] if plan_features.blank?
|
||||
|
||||
plan_features[plan_name]
|
||||
end
|
||||
|
||||
def captain_monthly_limit
|
||||
default_limits = default_captain_limits
|
||||
|
||||
{
|
||||
documents: self[:limits][CAPTAIN_DOCUMENTS] || default_limits['documents'],
|
||||
responses: self[:limits][CAPTAIN_RESPONSES] || default_limits['responses']
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get_captain_limits(type)
|
||||
total_count = captain_monthly_limit[type.to_s].to_i
|
||||
|
||||
consumed = if type == :documents
|
||||
custom_attributes[CAPTAIN_DOCUMENTS_USAGE].to_i || 0
|
||||
else
|
||||
custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0
|
||||
end
|
||||
|
||||
consumed = 0 if consumed.negative?
|
||||
|
||||
{
|
||||
total_count: total_count,
|
||||
current_available: (total_count - consumed).clamp(0, total_count),
|
||||
consumed: consumed
|
||||
}
|
||||
end
|
||||
|
||||
def plan_email_limit
|
||||
config = InstallationConfig.find_by(name: 'ACCOUNT_EMAILS_PLAN_LIMITS')&.value
|
||||
return nil if config.blank? || plan_name.blank?
|
||||
|
||||
parsed = config.is_a?(String) ? JSON.parse(config) : config
|
||||
parsed[plan_name.downcase]&.to_i
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
|
||||
def default_captain_limits
|
||||
max_limits = { documents: ChatwootApp.max_limit, responses: ChatwootApp.max_limit }.with_indifferent_access
|
||||
zero_limits = { documents: 0, responses: 0 }.with_indifferent_access
|
||||
plan_quota = InstallationConfig.find_by(name: 'CAPTAIN_CLOUD_PLAN_LIMITS')&.value
|
||||
|
||||
# If there are no limits configured, we allow max usage
|
||||
return max_limits if plan_quota.blank?
|
||||
|
||||
# if there is plan_quota configred, but plan_name is not present, we return zero limits
|
||||
return zero_limits if plan_name.blank?
|
||||
|
||||
begin
|
||||
# Now we parse the plan_quota and return the limits for the plan name
|
||||
# but if there's no plan_name present in the plan_quota, we return zero limits
|
||||
plan_quota = JSON.parse(plan_quota) if plan_quota.present?
|
||||
plan_quota[plan_name.downcase] || zero_limits
|
||||
rescue StandardError
|
||||
# if there's any error in parsing the plan_quota, we return max limits
|
||||
# this is to ensure that we don't block the user from using the product
|
||||
max_limits
|
||||
end
|
||||
end
|
||||
|
||||
def plan_name
|
||||
custom_attributes['plan_name']
|
||||
end
|
||||
|
||||
def agent_limits
|
||||
subscribed_quantity = custom_attributes['subscribed_quantity']
|
||||
subscribed_quantity || get_limits(:agents)
|
||||
end
|
||||
|
||||
def get_limits(limit_name)
|
||||
config_name = "ACCOUNT_#{limit_name.to_s.upcase}_LIMIT"
|
||||
return self[:limits][limit_name.to_s] if self[:limits][limit_name.to_s].present?
|
||||
|
||||
return GlobalConfig.get(config_name)[config_name] if GlobalConfig.get(config_name)[config_name].present?
|
||||
|
||||
ChatwootApp.max_limit
|
||||
end
|
||||
|
||||
def validate_limit_keys
|
||||
errors.add(:limits, ': Invalid data') unless self[:limits].is_a? Hash
|
||||
self[:limits] = {} if self[:limits].blank?
|
||||
|
||||
limit_schema = {
|
||||
'type' => 'object',
|
||||
'properties' => {
|
||||
'inboxes' => { 'type': 'number' },
|
||||
'agents' => { 'type': 'number' },
|
||||
'captain_responses' => { 'type': 'number' },
|
||||
'captain_documents' => { 'type': 'number' },
|
||||
'emails' => { 'type': 'number' }
|
||||
},
|
||||
'required' => [],
|
||||
'additionalProperties' => false
|
||||
}
|
||||
|
||||
errors.add(:limits, ': Invalid data') unless JSONSchemer.schema(limit_schema).valid?(self[:limits])
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
module Enterprise::AccountUser
|
||||
def permissions
|
||||
custom_role.present? ? (custom_role.permissions + ['custom_role']) : super
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
module Enterprise::ActivityMessageHandler
|
||||
def automation_status_change_activity_content
|
||||
return super unless Current.executed_by.instance_of?(Captain::Assistant)
|
||||
|
||||
locale = Current.executed_by.account.locale
|
||||
key = captain_activity_key
|
||||
return unless key
|
||||
|
||||
I18n.t(key, user_name: Current.executed_by.name, reason: Current.captain_resolve_reason, locale: locale)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def captain_activity_key
|
||||
if resolved? && Current.captain_resolve_reason.present?
|
||||
'conversations.activity.captain.resolved_by_tool'
|
||||
elsif resolved?
|
||||
'conversations.activity.captain.resolved'
|
||||
elsif open?
|
||||
'conversations.activity.captain.open'
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
module Enterprise::ApplicationRecord
|
||||
def droppables
|
||||
super + %w[SlaPolicy]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
module Enterprise::Audit::Account
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
audited except: :updated_at, on: [:update]
|
||||
has_associated_audits
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
module Enterprise::Audit::AccountUser
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
audited only: [
|
||||
:availability,
|
||||
:role,
|
||||
:account_id,
|
||||
:inviter_id,
|
||||
:user_id
|
||||
], on: [:create, :update], associated_with: :account
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
module Enterprise::Audit::AutomationRule
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
audited associated_with: :account
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
module Enterprise::Audit::Conversation
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
audited only: [], on: [:destroy]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
module Enterprise::Audit::Inbox
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
audited associated_with: :account, on: [:create, :update]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
module Enterprise::Audit::InboxMember
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_commit :create_audit_log_entry_on_create, on: :create
|
||||
after_commit :create_audit_log_entry_on_delete, on: :destroy
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_audit_log_entry_on_create
|
||||
create_audit_log_entry('create')
|
||||
end
|
||||
|
||||
def create_audit_log_entry_on_delete
|
||||
create_audit_log_entry('destroy')
|
||||
end
|
||||
|
||||
def create_audit_log_entry(action)
|
||||
return if inbox.blank?
|
||||
|
||||
Enterprise::AuditLog.create(
|
||||
auditable_id: id,
|
||||
auditable_type: 'InboxMember',
|
||||
action: action,
|
||||
associated_id: inbox&.account_id,
|
||||
audited_changes: attributes.except('updated_at', 'created_at'),
|
||||
associated_type: 'Account'
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
module Enterprise::Audit::Macro
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
audited associated_with: :account
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
module Enterprise::Audit::Team
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
audited associated_with: :account
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
module Enterprise::Audit::TeamMember
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_commit :create_audit_log_entry_on_create, on: :create
|
||||
after_commit :create_audit_log_entry_on_delete, on: :destroy
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_audit_log_entry_on_create
|
||||
create_audit_log_entry('create')
|
||||
end
|
||||
|
||||
def create_audit_log_entry_on_delete
|
||||
create_audit_log_entry('destroy')
|
||||
end
|
||||
|
||||
def create_audit_log_entry(action)
|
||||
return if team.blank?
|
||||
|
||||
Enterprise::AuditLog.create(
|
||||
auditable_id: id,
|
||||
auditable_type: 'TeamMember',
|
||||
action: action,
|
||||
associated_id: team&.account_id,
|
||||
audited_changes: attributes.except('updated_at', 'created_at'),
|
||||
associated_type: 'Account'
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
module Enterprise::Audit::User
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
# required only for sign_in and sign_out events, which we are logging manually
|
||||
# hence the proc that always returns false
|
||||
audited only: [
|
||||
:availability,
|
||||
:display_name,
|
||||
:email,
|
||||
:name
|
||||
], unless: proc { |_u| true }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
module Enterprise::Audit::Webhook
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
audited associated_with: :account
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: audits
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# action :string
|
||||
# associated_type :string
|
||||
# auditable_type :string
|
||||
# audited_changes :jsonb
|
||||
# comment :string
|
||||
# remote_address :string
|
||||
# request_uuid :string
|
||||
# user_type :string
|
||||
# username :string
|
||||
# version :integer default(0)
|
||||
# created_at :datetime
|
||||
# associated_id :bigint
|
||||
# auditable_id :bigint
|
||||
# user_id :bigint
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# associated_index (associated_type,associated_id)
|
||||
# auditable_index (auditable_type,auditable_id,version)
|
||||
# index_audits_on_created_at (created_at)
|
||||
# index_audits_on_request_uuid (request_uuid)
|
||||
# user_index (user_id,user_type)
|
||||
#
|
||||
class Enterprise::AuditLog < Audited::Audit
|
||||
after_save :log_additional_information
|
||||
|
||||
private
|
||||
|
||||
def log_additional_information
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
if auditable_type == 'Account' && auditable_id.present?
|
||||
update_columns(associated_type: auditable_type, associated_id: auditable_id, username: user&.email)
|
||||
else
|
||||
update_columns(username: user&.email)
|
||||
end
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
module Enterprise::AutomationRule
|
||||
def conditions_attributes
|
||||
super + %w[sla_policy_id]
|
||||
end
|
||||
|
||||
def actions_attributes
|
||||
super + %w[add_sla]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,45 @@
|
||||
module Enterprise::Channelable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# Active support concern has `included` which changes the order of the method lookup chain
|
||||
# https://stackoverflow.com/q/40061982/3824876
|
||||
# manually prepend the instance methods to combat this
|
||||
included do
|
||||
prepend InstanceMethods
|
||||
end
|
||||
|
||||
module InstanceMethods
|
||||
def create_audit_log_entry
|
||||
account = self.account
|
||||
associated_type = 'Account'
|
||||
|
||||
return if inbox.nil?
|
||||
|
||||
auditable_id = inbox.id
|
||||
auditable_type = 'Inbox'
|
||||
audited_changes = saved_changes.except('updated_at')
|
||||
|
||||
return if audited_changes.blank?
|
||||
|
||||
# skip audit log creation if the only change is whatsapp channel template update
|
||||
return if messaging_template_updates?(audited_changes)
|
||||
|
||||
Enterprise::AuditLog.create(
|
||||
auditable_id: auditable_id,
|
||||
auditable_type: auditable_type,
|
||||
action: 'update',
|
||||
associated_id: account.id,
|
||||
associated_type: associated_type,
|
||||
audited_changes: audited_changes
|
||||
)
|
||||
end
|
||||
|
||||
def messaging_template_updates?(changes)
|
||||
# if there is more than one key, return false
|
||||
return false unless changes.keys.length == 1
|
||||
|
||||
# if the only key is message_templates_last_updated, return true
|
||||
changes.key?('message_templates_last_updated')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
module Enterprise::Concerns::Account
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
store_accessor :settings, :conversation_required_attributes
|
||||
|
||||
has_many :sla_policies, dependent: :destroy_async
|
||||
has_many :applied_slas, dependent: :destroy_async
|
||||
has_many :custom_roles, dependent: :destroy_async
|
||||
has_many :agent_capacity_policies, dependent: :destroy_async
|
||||
|
||||
has_many :captain_assistants, dependent: :destroy_async, class_name: 'Captain::Assistant'
|
||||
has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse'
|
||||
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
|
||||
has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool'
|
||||
|
||||
has_many :copilot_threads, dependent: :destroy_async
|
||||
has_many :companies, dependent: :destroy_async
|
||||
has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice'
|
||||
|
||||
has_one :saml_settings, dependent: :destroy_async, class_name: 'AccountSamlSettings'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
module Enterprise::Concerns::AccountUser
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
belongs_to :custom_role, optional: true
|
||||
belongs_to :agent_capacity_policy, optional: true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,83 @@
|
||||
module Enterprise::Concerns::Article
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_save :add_article_embedding, if: -> { saved_change_to_title? || saved_change_to_description? || saved_change_to_content? }
|
||||
|
||||
def self.add_article_embedding_association
|
||||
has_many :article_embeddings, dependent: :destroy_async
|
||||
end
|
||||
|
||||
add_article_embedding_association
|
||||
|
||||
def self.vector_search(params)
|
||||
embedding = Captain::Llm::EmbeddingService.new(account_id: params[:account_id]).get_embedding(params['query'])
|
||||
records = joins(
|
||||
:category
|
||||
).search_by_category_slug(
|
||||
params[:category_slug]
|
||||
).search_by_category_locale(params[:locale]).search_by_author(params[:author_id]).search_by_status(params[:status])
|
||||
filtered_article_ids = records.pluck(:id)
|
||||
|
||||
# Fetch nearest neighbors and their distances, then filter directly
|
||||
|
||||
# experimenting with filtering results based on result threshold
|
||||
# distance_threshold = 0.2
|
||||
# if using add the filter block to the below query
|
||||
# .filter { |ae| ae.neighbor_distance <= distance_threshold }
|
||||
|
||||
article_ids = ArticleEmbedding.where(article_id: filtered_article_ids)
|
||||
.nearest_neighbors(:embedding, embedding, distance: 'cosine')
|
||||
.limit(5)
|
||||
.pluck(:article_id)
|
||||
|
||||
# Fetch the articles by the IDs obtained from the nearest neighbors search
|
||||
where(id: article_ids)
|
||||
end
|
||||
end
|
||||
|
||||
def add_article_embedding
|
||||
return unless account.feature_enabled?('help_center_embedding_search')
|
||||
|
||||
Portal::ArticleIndexingJob.perform_later(self)
|
||||
end
|
||||
|
||||
def generate_and_save_article_seach_terms
|
||||
terms = generate_article_search_terms
|
||||
article_embeddings.destroy_all
|
||||
terms.each { |term| article_embeddings.create!(term: term) }
|
||||
end
|
||||
|
||||
def article_to_search_terms_prompt
|
||||
<<~SYSTEM_PROMPT_MESSAGE
|
||||
For the provided article content, generate potential search query keywords and snippets that can be used to generate the embeddings.
|
||||
Ensure the search terms are as diverse as possible but capture the essence of the article and are super related to the articles.
|
||||
Don't return any terms if there aren't any terms of relevance.
|
||||
Always return results in valid JSON of the following format
|
||||
{
|
||||
"search_terms": []
|
||||
}
|
||||
SYSTEM_PROMPT_MESSAGE
|
||||
end
|
||||
|
||||
def generate_article_search_terms
|
||||
messages = [
|
||||
{ role: 'system', content: article_to_search_terms_prompt },
|
||||
{ role: 'user', content: "title: #{title} \n description: #{description} \n content: #{content}" }
|
||||
]
|
||||
headers = { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{ENV.fetch('OPENAI_API_KEY', nil)}" }
|
||||
body = { model: 'gpt-4o', messages: messages, response_format: { type: 'json_object' } }.to_json
|
||||
Rails.logger.info "Requesting Chat GPT with body: #{body}"
|
||||
response = HTTParty.post(openai_api_url, headers: headers, body: body)
|
||||
Rails.logger.info "Chat GPT response: #{response.body}"
|
||||
JSON.parse(response.parsed_response['choices'][0]['message']['content'])['search_terms']
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def openai_api_url
|
||||
endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || 'https://api.openai.com/'
|
||||
endpoint = endpoint.chomp('/')
|
||||
"#{endpoint}/v1/chat/completions"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
module Enterprise::Concerns::AssignmentPolicy
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
enum assignment_order: { round_robin: 0, balanced: 1 } if ChatwootApp.enterprise?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
module Enterprise::Concerns::Attachment
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_create_commit :enqueue_audio_transcription
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def enqueue_audio_transcription
|
||||
return unless file_type.to_sym == :audio
|
||||
|
||||
Messages::AudioTranscriptionJob.perform_later(id)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
module Enterprise::Concerns::Contact
|
||||
extend ActiveSupport::Concern
|
||||
included do
|
||||
belongs_to :company, optional: true, counter_cache: true
|
||||
|
||||
after_commit :associate_company_from_email,
|
||||
on: [:create, :update],
|
||||
if: :should_associate_company?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def should_associate_company?
|
||||
# Only trigger if:
|
||||
# 1. Contact has an email
|
||||
# 2. Contact doesn't have a compan yet
|
||||
# 3. Email was just set/changed
|
||||
# 4. Email was previously nil (first time getting email)
|
||||
email.present? &&
|
||||
company_id.nil? &&
|
||||
saved_change_to_email? &&
|
||||
saved_change_to_email.first.nil?
|
||||
end
|
||||
|
||||
def associate_company_from_email
|
||||
Contacts::CompanyAssociationService.new.associate_company_from_email(self)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Failed to associate company for contact #{id}: #{e.message}")
|
||||
# Don't fail the contact save if the company association fails
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,39 @@
|
||||
module Enterprise::Concerns::Conversation
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
belongs_to :sla_policy, optional: true
|
||||
has_one :applied_sla, dependent: :destroy_async
|
||||
has_many :sla_events, dependent: :destroy_async
|
||||
has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable
|
||||
before_validation :validate_sla_policy, if: -> { sla_policy_id_changed? }
|
||||
around_save :ensure_applied_sla_is_created, if: -> { sla_policy_id_changed? }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_sla_policy
|
||||
# TODO: remove these validations once we figure out how to deal with these cases
|
||||
if sla_policy_id.nil? && changes[:sla_policy_id].first.present?
|
||||
errors.add(:sla_policy, 'cannot remove sla policy from conversation')
|
||||
return
|
||||
end
|
||||
|
||||
if changes[:sla_policy_id].first.present?
|
||||
errors.add(:sla_policy, 'conversation already has a different sla')
|
||||
return
|
||||
end
|
||||
|
||||
errors.add(:sla_policy, 'sla policy account mismatch') if sla_policy&.account_id != account_id
|
||||
end
|
||||
|
||||
# handling inside a transaction to ensure applied sla record is also created
|
||||
def ensure_applied_sla_is_created
|
||||
ActiveRecord::Base.transaction do
|
||||
yield
|
||||
create_applied_sla(sla_policy_id: sla_policy_id) if applied_sla.blank?
|
||||
end
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
module Enterprise::Concerns::CustomAttributeDefinition
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_destroy :cleanup_conversation_required_attributes
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def cleanup_conversation_required_attributes
|
||||
return unless conversation_attribute? && account.conversation_required_attributes&.include?(attribute_key)
|
||||
|
||||
account.conversation_required_attributes = account.conversation_required_attributes - [attribute_key]
|
||||
account.save!
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
module Enterprise::Concerns::Inbox
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_one :captain_inbox, dependent: :destroy, class_name: 'CaptainInbox'
|
||||
has_one :captain_assistant,
|
||||
through: :captain_inbox,
|
||||
class_name: 'Captain::Assistant'
|
||||
has_many :inbox_capacity_limits, dependent: :destroy
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
module Enterprise::Concerns::Portal
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_save :enqueue_cloudflare_verification, if: :saved_change_to_custom_domain?
|
||||
end
|
||||
|
||||
def enqueue_cloudflare_verification
|
||||
return if custom_domain.blank?
|
||||
return unless ChatwootApp.chatwoot_cloud?
|
||||
|
||||
Enterprise::CloudflareVerificationJob.perform_later(id)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
module Enterprise::Concerns::User
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_validation :ensure_installation_pricing_plan_quantity, on: :create
|
||||
|
||||
has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable
|
||||
has_many :copilot_threads, dependent: :destroy_async
|
||||
end
|
||||
|
||||
def ensure_installation_pricing_plan_quantity
|
||||
return unless ChatwootHub.pricing_plan == 'premium'
|
||||
|
||||
errors.add(:base, 'User limit reached. Please purchase more licenses from super admin') if User.count >= ChatwootHub.pricing_plan_quantity
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
module Enterprise::Conversation
|
||||
def list_of_keys
|
||||
super + %w[sla_policy_id]
|
||||
end
|
||||
|
||||
# Include select additional_attributes keys (call related) for update events
|
||||
def allowed_keys?
|
||||
return true if super
|
||||
|
||||
attrs_change = previous_changes['additional_attributes']
|
||||
return false unless attrs_change.is_a?(Array) && attrs_change[1].is_a?(Hash)
|
||||
|
||||
changed_attr_keys = attrs_change[1].keys
|
||||
changed_attr_keys.intersect?(%w[call_status])
|
||||
end
|
||||
end
|
||||
40
research/chatwoot/enterprise/app/models/enterprise/inbox.rb
Normal file
40
research/chatwoot/enterprise/app/models/enterprise/inbox.rb
Normal file
@@ -0,0 +1,40 @@
|
||||
module Enterprise::Inbox
|
||||
def member_ids_with_assignment_capacity
|
||||
return super unless enable_auto_assignment?
|
||||
|
||||
max_assignment_limit = auto_assignment_config['max_assignment_limit']
|
||||
overloaded_agent_ids = max_assignment_limit.present? ? get_agent_ids_over_assignment_limit(max_assignment_limit) : []
|
||||
super - overloaded_agent_ids
|
||||
end
|
||||
|
||||
def active_bot?
|
||||
super || captain_active?
|
||||
end
|
||||
|
||||
def captain_active?
|
||||
captain_assistant.present? && more_responses?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def more_responses?
|
||||
account.usage_limits[:captain][:responses][:current_available].positive?
|
||||
end
|
||||
|
||||
def get_agent_ids_over_assignment_limit(limit)
|
||||
conversations
|
||||
.open
|
||||
.where(account_id: account_id)
|
||||
.select(:assignee_id)
|
||||
.group(:assignee_id)
|
||||
.having("count(*) >= #{limit.to_i}")
|
||||
.filter_map(&:assignee_id)
|
||||
end
|
||||
|
||||
def ensure_valid_max_assignment_limit
|
||||
return if auto_assignment_config['max_assignment_limit'].blank?
|
||||
return if auto_assignment_config['max_assignment_limit'].to_i.positive?
|
||||
|
||||
errors.add(:auto_assignment_config, 'max_assignment_limit must be greater than 0')
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
module Enterprise::InboxAgentAvailability
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def member_ids_with_assignment_capacity
|
||||
return member_ids unless capacity_filtering_enabled?
|
||||
|
||||
# Get online agents with capacity
|
||||
agents = available_agents
|
||||
agents = filter_by_capacity(agents)
|
||||
agents.map(&:user_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def filter_by_capacity(inbox_members_scope)
|
||||
return inbox_members_scope unless capacity_filtering_enabled?
|
||||
|
||||
inbox_members_scope.select do |inbox_member|
|
||||
capacity_service.agent_has_capacity?(inbox_member.user, self)
|
||||
end
|
||||
end
|
||||
|
||||
def capacity_filtering_enabled?
|
||||
account.feature_enabled?('assignment_v2') &&
|
||||
account.account_users.joins(:agent_capacity_policy).exists?
|
||||
end
|
||||
|
||||
def capacity_service
|
||||
@capacity_service ||= Enterprise::AutoAssignment::CapacityService.new
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,24 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: inbox_capacity_limits
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# conversation_limit :integer not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# agent_capacity_policy_id :bigint not null
|
||||
# inbox_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_on_agent_capacity_policy_id_inbox_id_71c7ec4caf (agent_capacity_policy_id,inbox_id) UNIQUE
|
||||
# index_inbox_capacity_limits_on_agent_capacity_policy_id (agent_capacity_policy_id)
|
||||
# index_inbox_capacity_limits_on_inbox_id (inbox_id)
|
||||
#
|
||||
class InboxCapacityLimit < ApplicationRecord
|
||||
belongs_to :agent_capacity_policy
|
||||
belongs_to :inbox
|
||||
|
||||
validates :conversation_limit, presence: true, numericality: { greater_than: 0, only_integer: true }
|
||||
validates :inbox_id, uniqueness: { scope: :agent_capacity_policy_id }
|
||||
end
|
||||
87
research/chatwoot/enterprise/app/models/sla_event.rb
Normal file
87
research/chatwoot/enterprise/app/models/sla_event.rb
Normal file
@@ -0,0 +1,87 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: sla_events
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# event_type :integer
|
||||
# meta :jsonb
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# applied_sla_id :bigint not null
|
||||
# conversation_id :bigint not null
|
||||
# inbox_id :bigint not null
|
||||
# sla_policy_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_sla_events_on_account_id (account_id)
|
||||
# index_sla_events_on_applied_sla_id (applied_sla_id)
|
||||
# index_sla_events_on_conversation_id (conversation_id)
|
||||
# index_sla_events_on_inbox_id (inbox_id)
|
||||
# index_sla_events_on_sla_policy_id (sla_policy_id)
|
||||
#
|
||||
class SlaEvent < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :inbox
|
||||
belongs_to :conversation
|
||||
belongs_to :sla_policy
|
||||
belongs_to :applied_sla
|
||||
|
||||
enum event_type: { frt: 0, nrt: 1, rt: 2 }
|
||||
|
||||
before_validation :ensure_applied_sla_id, :ensure_account_id, :ensure_inbox_id, :ensure_sla_policy_id
|
||||
after_create_commit :create_notifications
|
||||
|
||||
def push_event_data
|
||||
{
|
||||
id: id,
|
||||
event_type: event_type,
|
||||
meta: meta,
|
||||
created_at: created_at.to_i,
|
||||
updated_at: updated_at.to_i
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_applied_sla_id
|
||||
self.applied_sla_id ||= AppliedSla.find_by(conversation_id: conversation_id)&.last&.id
|
||||
end
|
||||
|
||||
def ensure_account_id
|
||||
self.account_id ||= conversation&.account_id
|
||||
end
|
||||
|
||||
def ensure_inbox_id
|
||||
self.inbox_id ||= conversation&.inbox_id
|
||||
end
|
||||
|
||||
def ensure_sla_policy_id
|
||||
self.sla_policy_id ||= applied_sla&.sla_policy_id
|
||||
end
|
||||
|
||||
def create_notifications
|
||||
notify_users = conversation.conversation_participants.map(&:user)
|
||||
# Add all admins from the account to notify list
|
||||
notify_users += account.administrators
|
||||
# Ensure conversation assignee is notified
|
||||
notify_users += [conversation.assignee] if conversation.assignee.present?
|
||||
|
||||
notification_type = {
|
||||
'frt' => 'sla_missed_first_response',
|
||||
'nrt' => 'sla_missed_next_response',
|
||||
'rt' => 'sla_missed_resolution'
|
||||
}[event_type]
|
||||
|
||||
notify_users.uniq.each do |user|
|
||||
NotificationBuilder.new(
|
||||
notification_type: notification_type,
|
||||
user: user,
|
||||
account: account,
|
||||
primary_actor: conversation,
|
||||
secondary_actor: sla_policy
|
||||
).perform
|
||||
end
|
||||
end
|
||||
end
|
||||
36
research/chatwoot/enterprise/app/models/sla_policy.rb
Normal file
36
research/chatwoot/enterprise/app/models/sla_policy.rb
Normal file
@@ -0,0 +1,36 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: sla_policies
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# description :string
|
||||
# first_response_time_threshold :float
|
||||
# name :string not null
|
||||
# next_response_time_threshold :float
|
||||
# only_during_business_hours :boolean default(FALSE)
|
||||
# resolution_time_threshold :float
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_sla_policies_on_account_id (account_id)
|
||||
#
|
||||
class SlaPolicy < ApplicationRecord
|
||||
belongs_to :account
|
||||
validates :name, presence: true
|
||||
|
||||
has_many :conversations, dependent: :nullify
|
||||
has_many :applied_slas, dependent: :destroy_async
|
||||
|
||||
def push_event_data
|
||||
{
|
||||
id: id,
|
||||
name: name,
|
||||
frt: first_response_time_threshold,
|
||||
nrt: next_response_time_threshold,
|
||||
rt: resolution_time_threshold
|
||||
}
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user