Restructure omni services and add Chatwoot research snapshot

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

View File

@@ -0,0 +1,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

View File

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

View File

@@ -0,0 +1,5 @@
module Enterprise::AccountUser
def permissions
custom_role.present? ? (custom_role.permissions + ['custom_role']) : super
end
end

View File

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

View File

@@ -0,0 +1,5 @@
module Enterprise::ApplicationRecord
def droppables
super + %w[SlaPolicy]
end
end

View File

@@ -0,0 +1,8 @@
module Enterprise::Audit::Account
extend ActiveSupport::Concern
included do
audited except: :updated_at, on: [:update]
has_associated_audits
end
end

View File

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

View File

@@ -0,0 +1,7 @@
module Enterprise::Audit::AutomationRule
extend ActiveSupport::Concern
included do
audited associated_with: :account
end
end

View File

@@ -0,0 +1,7 @@
module Enterprise::Audit::Conversation
extend ActiveSupport::Concern
included do
audited only: [], on: [:destroy]
end
end

View File

@@ -0,0 +1,7 @@
module Enterprise::Audit::Inbox
extend ActiveSupport::Concern
included do
audited associated_with: :account, on: [:create, :update]
end
end

View File

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

View File

@@ -0,0 +1,7 @@
module Enterprise::Audit::Macro
extend ActiveSupport::Concern
included do
audited associated_with: :account
end
end

View File

@@ -0,0 +1,7 @@
module Enterprise::Audit::Team
extend ActiveSupport::Concern
included do
audited associated_with: :account
end
end

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
module Enterprise::Audit::Webhook
extend ActiveSupport::Concern
included do
audited associated_with: :account
end
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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